diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..fa1385d99a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* -text diff --git a/.gitignore b/.gitignore index 69bc47afdd..4fd90cfe03 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,5 @@ node_modules .prereqs_cache autodeploy.properties .ws_migrations_complete +.vagrant/ +logs diff --git a/.tx/config b/.tx/config index 540c4732af..9288418924 100644 --- a/.tx/config +++ b/.tx/config @@ -1,25 +1,25 @@ [main] host = https://www.transifex.com -[edx-studio.django-partial] +[edx-platform.django-partial] file_filter = conf/locale//LC_MESSAGES/django-partial.po source_file = conf/locale/en/LC_MESSAGES/django-partial.po source_lang = en type = PO -[edx-studio.djangojs] +[edx-platform.djangojs] file_filter = conf/locale//LC_MESSAGES/djangojs.po source_file = conf/locale/en/LC_MESSAGES/djangojs.po source_lang = en type = PO -[edx-studio.mako] +[edx-platform.mako] file_filter = conf/locale//LC_MESSAGES/mako.po source_file = conf/locale/en/LC_MESSAGES/mako.po source_lang = en type = PO -[edx-studio.messages] +[edx-platform.messages] file_filter = conf/locale//LC_MESSAGES/messages.po source_file = conf/locale/en/LC_MESSAGES/messages.po source_lang = en diff --git a/AUTHORS b/AUTHORS index 9bb4ede121..c700eab277 100644 --- a/AUTHORS +++ b/AUTHORS @@ -78,3 +78,9 @@ Peter Fogg Bethany LaPenta Renzo Lucioni Felix Sun +Adam Palay +Ian Hoover +Mukul Goyal +Robert Marks +Yarko Tymciurak + diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f865c09840..51a98f2de7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,8 +5,77 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Studio: Send e-mails to new Studio users (on edge only) when their course creator +status has changed. This will not be in use until the course creator table +is enabled. + +LMS: Added user preferences (arbitrary user/key/value tuples, for which +which user/key is unique) and a REST API for reading users and +preferences. Access to the REST API is restricted by use of the +X-Edx-Api-Key HTTP header (which must match settings.EDX_API_KEY; if +the setting is not present, the API is disabled). + +LMS: Added endpoints for AJAX requests to enable/disable notifications +(which are not yet implemented) and a one-click unsubscribe page. + +Common: Add a manage.py that knows about edx-platform specific settings and projects + +Common: Added *experimental* support for jsinput type. + +Common: Added setting to specify Celery Broker vhost + +Common: Utilize new XBlock bulk save API in LMS and CMS. + +Studio: Add table for tracking course creator permissions (not yet used). +Update rake django-admin[syncdb] and rake django-admin[migrate] so they +run for both LMS and CMS. + +LMS: Added *experimental* crowdsource hinting manager page. + +XModule: Added *experimental* crowdsource hinting module. + +Studio: Added support for uploading and managing PDF textbooks + +Common: Student information is now passed to the tracking log via POST instead of GET. + +Blades: Added functionality and tests for new capa input type: choicetextresponse. + +Common: Add tests for documentation generation to test suite + +Blades: User answer now preserved (and changeable) after clicking "show answer" in choice problems + +LMS: Removed press releases + +Common: Updated Sass and Bourbon libraries, added Neat library + +LMS: Users are no longer auto-activated if they click "reset password" +This is now done when they click on the link in the reset password +email they receive (along with usual path through activation email). + +LMS: Fixed a reflected XSS problem in the static textbook views. + +LMS: Problem rescoring. Added options on the Grades tab of the +Instructor Dashboard to allow a particular student's submission for a +particular problem to be rescored. Provides an option to see a +history of background tasks for a given problem and student. + +Blades: Small UX fix on capa multiple-choice problems. Make labels only +as wide as the text to reduce accidental choice selections. + +Studio: +- use xblock field defaults to initialize all new instances' fields and +only use templates as override samples. +- create new instances via in memory create_xmodule and related methods rather +than cloning a db record. +- have an explicit method for making a draft copy as distinct from making a new module. + +Studio: Remove XML from the video component editor. All settings are +moved to be edited as metadata. + XModule: Only write out assets files if the contents have changed. +Studio: Course settings are now saved explicitly. + XModule: Don't delete generated xmodule asset files when compiling (for instance, when XModule provides a coffeescript file, don't delete the associated javascript) @@ -45,6 +114,8 @@ setting now run entirely outside the Python sandbox. Blades: Added tests for Video Alpha player. +Common: Have the capa module handle unicode better (especially errors) + Blades: Video Alpha bug fix for speed changing to 1.0 in Firefox. Blades: Additional event tracking added to Video Alpha: fullscreen switch, show/hide @@ -135,3 +206,5 @@ Common: Updated CodeJail. Common: Allow setting of authentication session cookie name. +LMS: Option to email students when enroll/un-enroll them. + diff --git a/Gemfile b/Gemfile index 1ad685c34d..8a6a2c8ccc 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,8 @@ source 'https://rubygems.org' gem 'rake', '~> 10.0.3' -gem 'sass', '3.1.15' -gem 'bourbon', '~> 1.3.6' +gem 'sass', '3.2.9' +gem 'bourbon', '~> 3.1.8' +gem 'neat', '~> 1.3.0' gem 'colorize', '~> 0.5.8' gem 'launchy', '~> 2.1.2' gem 'sys-proctable', '~> 0.9.3' diff --git a/LICENSE b/LICENSE index dba13ed2dd..1eb391f388 100644 --- a/LICENSE +++ b/LICENSE @@ -659,3 +659,13 @@ specific requirements. if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . + +EdX Inc. wishes to state, in clarification of the above license terms, that +any public, independently available web service offered over the network and +communicating with edX's copyrighted works by any form of inter-service +communication, including but not limited to Remote Procedure Call (RPC) +interfaces, is not a work based on our copyrighted work within the meaning +of the license. "Corresponding Source" of this work, or works based on this +work, as defined by the terms of this license do not include source code +files for programs used solely to provide those public, independently +available web services. diff --git a/README.md b/README.md index 92a4116354..0261f87b46 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,200 @@ This is the main edX platform which consists of LMS and Studio. See [code.edx.org](http://code.edx.org/) for other parts of the edX code base. -Installation -============ +Installation - The first time +============================= + +The following instructions will help you to download and setup a virtual machine +with a minimal amount of steps, using Vagrant. It is recommended for a first +installation, as it will save you from many of the common pitfalls of the +installation process. + +1. Make sure you have plenty of available disk space, >5GB +2. Install Git: http://git-scm.com/downloads +3. Install VirtualBox: https://www.virtualbox.org/wiki/Downloads + See http://docs.vagrantup.com/v2/providers/index.html for a list of supported + Providers. You should use VirtualBox >= 4.2.12. + (Windows: later/earlier VirtualBox versions than 4.2.12 have been reported to not work well with + Vagrant. If this is still a problem, you can + install 4.2.12 from http://download.virtualbox.org/virtualbox/4.2.12/). +4. Install Vagrant: http://www.vagrantup.com/ (Vagrant 1.2.2 or later) +5. Open a terminal +6. Download the project: `git clone https://github.com/edx/edx-platform.git` +7. Enter the project directory: `cd edx-platform/` +8. (Windows only) Run the commands to + [deal with line endings and symlinks under Windows](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#dealing-with-line-endings-and-symlinks-under-windows) +9. Create the development environment and start it: `vagrant up` + +The initial `vagrant up` will download a Linux image, then boot and ask for your +host machine's administrator password to setup file sharing between your computer and the VM. +Once file sharing is established, `edx-platform/scripts/create-dev-env.sh` will +install dependencies and configure the VM. +This will take a while; go grab a coffee. + +When complete, you should see a _"Success!"_ message. +If not, refer to the +[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting). + +Your development environment is initialized only on the first bring-up. +Subsequently `vagrant up` commands will boot your virtual machine normally. + +Note: by default, the VM will get the IP `192.168.20.40`. +You can change this in your `Vagrantfile` (the startup message will reflect your VM's actual IP). + +Accessing the VM +---------------- + +Once the installation is finished, to log into the virtual machine: + +``` +$ vagrant ssh +``` + +Note: This won't work from Windows. Instead, install PuTTY from +http://www.chiark.greenend.org.uk/%7Esgtatham/putty/download.html. Then +connect to 192.168.20.40, port 2222, using vagrant/vagrant as a user/password. + + +Using edX +--------- + +When you login to your VM, you are in +`/opt/edx/edx-platform` by default, which is shared from your host workspace. +Your host computer contains the edx-project development code and repository. +Your VM runs edx-platform code mounted from your host, so +you can develop by editing on your host. + +After logging into your VM with `vagrant ssh`, +start the _Studio_ and +_Learning management system (LMS)_ +servers (run these from `/opt/edx/edx-platform`): + +Learning management system (LMS): + +``` +$ rake lms[cms.dev,0.0.0.0:8000] +``` + +Studio (CMS): + +``` +$ rake cms[dev,0.0.0.0:8001] +``` + +The servers will come up to these URLs: + +- LMS: http://192.168.20.40:8000/ +- CMS: http://192.168.20.40:8001/ + +Your VM's port 8000 is forwarded to host port 9000 +so you can also access the LMS with [http://localhost:9000/](). +Similarly, VM port 8001 is forwarded to host port 9001. +These are set in your `Vagrantfile`. + +Note that when you register a new user through the web interface, +by default the activiation email will be appear on your VM's terminal. +Search for lines similar to: + +``` +Subject: Your account for edX Studio +From: registration@edx.org +``` + +and find the activation URL. + +See the [Frequently Asked Questions](https://github.com/edx/edx-platform/wiki/Frequently-Asked-Questions) +for more usage tips. + +Django admin & debug toolbar +----------------------------- + +You can enable admin logins and the debug_toolbar by editing +`lms/envs/common.py`: + +- enable ADMIN login page by setting: + - ``` + 'ENABLE_DJANGO_ADMIN_SITE': True +``` + + +- enable debug toolbar by uncommenting: + - ``` + # 'debug_toolbar.middleware.DebugToolbarMiddleware', +``` + +These are also defined in `lms/envs/dev.py`, +and usually active on localhost. + +To get at your VM's 127.0.0.1, explicitly forward one of VM's available localhost ports to your computer. +Instead of `vagrant ssh`, login with: + +``` +$ ssh -L 6080:127.0.0.1:8080 vagrant@192.168.20.40 +``` + +The password is _vagrant_. + +From your VM, start the LMS as a localhost instance: + +``` +$ rake lms[cms.dev,127.0.0.1:8080] +``` + +You should see the debug toolbar now on [http:/localhost:6080/](). +You should now also see a login on [http://localhost:6080/admin/]() +You will need a privileged user for the admin login. +You can create a CMS/LMS super-user with: +``` +$ ./manage.py lms createsuperuser +``` + + +Stopping & starting +------------------- + + +To stop the VM (from your `edx-platform/` directory): +``` +$ vagrant halt +``` + +To restart: + +``` +$ vagrant up +``` + +To suspend and resume tasks in progress on your VM: +``` +$ vagrant suspend +$ # and later... +$ vagrant resume +``` + +Your development environment is normally created once, on first `vagrant up`. +You can continue to fetch changes in edx-platform +as you work with your VM. +To re-create your VM and create a fresh development environment: +``` +$ vagrant destroy +$ vagrant up # will make a new VM +``` + + +Troubleshooting +--------------- + +If anything doesn't work as expected, see the +[troubleshooting section](https://github.com/edx/edx-platform/wiki/Simplified-install-with-vagrant#troubleshooting). + +Installation - Advanced +======================= + +Note: The following installation instructions are for advanced users & developers +who are familiar with setting up Python, Ruby & node.js virtual environments. +Even if you know what you are doing, edX has a large code base with multiple +dependencies, so you might still want to use the method described above the +first time, as Vagrant helps avoiding issues due to the different environments. There is a `scripts/create-dev-env.sh` that will attempt to set up a development environment. @@ -101,24 +293,12 @@ or any other process management tool. Configuring Your Project ------------------------ -We use [`rake`](http://rake.rubyforge.org/) to execute common tasks in our -project. The `rake` tasks are defined in the `rakefile`, or you can run `rake -T` -to view a summary. - Before you run your project, you need to create a sqlite database, create -tables in that database, run database migrations, and populate templates for -CMS templates. Fortunately, `rake` will do all of this for you! Just run: +tables in that database, and run database migrations. Fortunately, `django` +will do all of this for you - $ rake django-admin[syncdb] - $ rake django-admin[migrate] - $ rake cms:update_templates - -If you are running these commands using the [`zsh`](http://www.zsh.org/) shell, -zsh will assume that you are doing -[shell globbing](https://en.wikipedia.org/wiki/Glob_%28programming%29), search for -a file in your directory named `django-adminsyncdb` or `django-adminmigrate`, -and fail. To fix this, just surround the argument with quotation marks, so that -you're running `rake "django-admin[syncdb]"`. + $ ./manage.py lms syncdb --migrate + $ ./manage.py cms syncdb --migrate Run Your Project ---------------- @@ -126,6 +306,10 @@ edX has two components: Studio, the course authoring system; and the LMS (learning management system) used by students. These two systems communicate through the MongoDB database, which stores course information. +We use [`rake`](http://rake.rubyforge.org/) to execute common tasks in our +project. The `rake` tasks are defined in the `rakefile`, or you can run `rake -T` +to view a summary. + To run Studio, run: $ rake cms @@ -152,6 +336,12 @@ otherwise noted. Please see ``LICENSE.txt`` for details. +Documentation +------------ + +High-level documentation of the code is located in the `doc` subdirectory. Start +with `overview.md` to get an introduction to the architecture of the system. + How to Contribute ----------------- diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000000..0d409cc408 --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,33 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : + +Vagrant.configure("2") do |config| + config.vm.box = "precise32" + config.vm.box_url = "http://files.vagrantup.com/precise32.box" + + config.vm.network :forwarded_port, guest: 8000, host: 9000 + config.vm.network :forwarded_port, guest: 8001, host: 9001 + + # Create a private network, which allows host-only access to the machine + # using a specific IP. + config.vm.network :private_network, ip: "192.168.20.40" + + nfs_setting = RUBY_PLATFORM =~ /darwin/ || RUBY_PLATFORM =~ /linux/ + config.vm.synced_folder ".", "/opt/edx/edx-platform", id: "vagrant-root", :nfs => nfs_setting + + # Make it so that network access from the vagrant guest is able to + # use SSH private keys that are present on the host without copying + # them into the VM. + config.ssh.forward_agent = true + + config.vm.provider :virtualbox do |vb| + # Use VBoxManage to customize the VM. For example to change memory: + vb.customize ["modifyvm", :id, "--memory", "1024"] + + # This setting makes it so that network access from inside the vagrant guest + # is able to resolve DNS using the hosts VPN connection. + vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] + end + + config.vm.provision :shell, :path => "scripts/vagrant-provisioning.sh" +end diff --git a/cms/djangoapps/auth/authz.py b/cms/djangoapps/auth/authz.py index 58b63abd23..0f2e60dd6e 100644 --- a/cms/djangoapps/auth/authz.py +++ b/cms/djangoapps/auth/authz.py @@ -1,5 +1,6 @@ from django.contrib.auth.models import User, Group from django.core.exceptions import PermissionDenied +from django.conf import settings from xmodule.modulestore import Location @@ -12,6 +13,9 @@ but this implementation should be data compatible with the LMS implementation INSTRUCTOR_ROLE_NAME = 'instructor' STAFF_ROLE_NAME = 'staff' +# This is the group of people who have permission to create new courses on edge or edx. +COURSE_CREATOR_GROUP_NAME = "course_creator_group" + # we're just making a Django group for each location/role combo # to do this we're just creating a Group name which is a formatted string # of those two variables @@ -32,14 +36,14 @@ def get_course_groupname_for_role(location, role): def get_users_in_course_group_by_role(location, role): groupname = get_course_groupname_for_role(location, role) - (group, created) = Group.objects.get_or_create(name=groupname) + (group, _created) = Group.objects.get_or_create(name=groupname) return group.user_set.all() -''' -Create all permission groups for a new course and subscribe the caller into those roles -''' def create_all_course_groups(creator, location): + """ + Create all permission groups for a new course and subscribe the caller into those roles + """ create_new_course_group(creator, location, INSTRUCTOR_ROLE_NAME) create_new_course_group(creator, location, STAFF_ROLE_NAME) @@ -55,11 +59,12 @@ def create_new_course_group(creator, location, role): return + def _delete_course_group(location): - ''' + """ This is to be called only by either a command line code path or through a app which has already asserted permissions - ''' + """ # remove all memberships instructors = Group.objects.get(name=get_course_groupname_for_role(location, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -71,11 +76,12 @@ def _delete_course_group(location): user.groups.remove(staff) user.save() + def _copy_course_group(source, dest): - ''' + """ This is to be called only by either a command line code path or through an app which has already asserted permissions to do this action - ''' + """ instructors = Group.objects.get(name=get_course_groupname_for_role(source, INSTRUCTOR_ROLE_NAME)) new_instructors_group = Group.objects.get(name=get_course_groupname_for_role(dest, INSTRUCTOR_ROLE_NAME)) for user in instructors.user_set.all(): @@ -94,10 +100,34 @@ def add_user_to_course_group(caller, user, location, role): if not is_user_in_course_group_role(caller, location, INSTRUCTOR_ROLE_NAME): raise PermissionDenied - if user.is_active and user.is_authenticated: - groupname = get_course_groupname_for_role(location, role) + group = Group.objects.get(name=get_course_groupname_for_role(location, role)) + return _add_user_to_group(user, group) - group = Group.objects.get(name=groupname) + +def add_user_to_creator_group(caller, user): + """ + Adds the user to the group of course creators. + + The caller must have staff access to perform this operation. + + Note that on the edX site, we currently limit course creators to edX staff, and this + method is a no-op in that environment. + """ + if not caller.is_active or not caller.is_authenticated or not caller.is_staff: + raise PermissionDenied + + (group, created) = Group.objects.get_or_create(name=COURSE_CREATOR_GROUP_NAME) + if created: + group.save() + return _add_user_to_group(user, group) + + +def _add_user_to_group(user, group): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ + if user.is_active and user.is_authenticated: user.groups.add(group) user.save() return True @@ -123,11 +153,29 @@ def remove_user_from_course_group(caller, user, location, role): # see if the user is actually in that role, if not then we don't have to do anything if is_user_in_course_group_role(user, location, role): - groupname = get_course_groupname_for_role(location, role) + _remove_user_from_group(user, get_course_groupname_for_role(location, role)) - group = Group.objects.get(name=groupname) - user.groups.remove(group) - user.save() + +def remove_user_from_creator_group(caller, user): + """ + Removes user from the course creator group. + + The caller must have staff access to perform this operation. + """ + if not caller.is_active or not caller.is_authenticated or not caller.is_staff: + raise PermissionDenied + + _remove_user_from_group(user, COURSE_CREATOR_GROUP_NAME) + + +def _remove_user_from_group(user, group_name): + """ + This is to be called only by either a command line code path or through an app which has already + asserted permissions to do this action + """ + group = Group.objects.get(name=group_name) + user.groups.remove(group) + user.save() def is_user_in_course_group_role(user, location, role): @@ -136,3 +184,52 @@ def is_user_in_course_group_role(user, location, role): return user.is_staff or user.groups.filter(name=get_course_groupname_for_role(location, role)).count() > 0 return False + + +def is_user_in_creator_group(user): + """ + Returns true if the user has permissions to create a course. + + Will always return True if user.is_staff is True. + + Note that on the edX site, we currently limit course creators to edX staff. On + other sites, this method checks that the user is in the course creator group. + """ + if user.is_staff: + return True + + # On edx, we only allow edX staff to create courses. This may be relaxed in the future. + if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False): + return False + + # Feature flag for using the creator group setting. Will be removed once the feature is complete. + if settings.MITX_FEATURES.get('ENABLE_CREATOR_GROUP', False): + return user.groups.filter(name=COURSE_CREATOR_GROUP_NAME).count() > 0 + + return True + + +def get_users_with_instructor_role(): + """ + Returns all users with the role 'instructor' + """ + return _get_users_with_role(INSTRUCTOR_ROLE_NAME) + + +def get_users_with_staff_role(): + """ + Returns all users with the role 'staff' + """ + return _get_users_with_role(STAFF_ROLE_NAME) + + +def _get_users_with_role(role): + """ + Returns all users with the specified role. + """ + users = set() + for group in Group.objects.all(): + if group.name.startswith(role + "_"): + for user in group.user_set.all(): + users.add(user) + return users diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py new file mode 100644 index 0000000000..e04c108250 --- /dev/null +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -0,0 +1,202 @@ +""" +Tests authz.py +""" +import mock + +from django.test import TestCase +from django.contrib.auth.models import User +from django.core.exceptions import PermissionDenied + +from auth.authz import add_user_to_creator_group, remove_user_from_creator_group, is_user_in_creator_group,\ + create_all_course_groups, add_user_to_course_group, STAFF_ROLE_NAME, INSTRUCTOR_ROLE_NAME,\ + is_user_in_course_group_role, remove_user_from_course_group, get_users_with_staff_role,\ + get_users_with_instructor_role + + +class CreatorGroupTest(TestCase): + """ + Tests for the course creator group. + """ + + def setUp(self): + """ Test case setup """ + self.user = User.objects.create_user('testuser', 'test+courses@edx.org', 'foo') + self.admin = User.objects.create_user('Mark', 'admin+courses@edx.org', 'foo') + self.admin.is_staff = True + + def test_creator_group_not_enabled(self): + """ + Tests that is_user_in_creator_group always returns True if ENABLE_CREATOR_GROUP + and DISABLE_COURSE_CREATION are both not turned on. + """ + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_creator_group_enabled_but_empty(self): + """ Tests creator group feature on, but group empty. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + self.assertFalse(is_user_in_creator_group(self.user)) + + # Make user staff. This will cause is_user_in_creator_group to return True. + self.user.is_staff = True + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_creator_group_enabled_nonempty(self): + """ Tests creator group feature on, user added. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + self.assertTrue(add_user_to_creator_group(self.admin, self.user)) + self.assertTrue(is_user_in_creator_group(self.user)) + + # check that a user who has not been added to the group still returns false + user_not_added = User.objects.create_user('testuser2', 'test+courses2@edx.org', 'foo2') + self.assertFalse(is_user_in_creator_group(user_not_added)) + + # remove first user from the group and verify that is_user_in_creator_group now returns false + remove_user_from_creator_group(self.admin, self.user) + self.assertFalse(is_user_in_creator_group(self.user)) + + def test_add_user_not_authenticated(self): + """ + Tests that adding to creator group fails if user is not authenticated + """ + self.user.is_authenticated = False + self.assertFalse(add_user_to_creator_group(self.admin, self.user)) + + def test_add_user_not_active(self): + """ + Tests that adding to creator group fails if user is not active + """ + self.user.is_active = False + self.assertFalse(add_user_to_creator_group(self.admin, self.user)) + + def test_course_creation_disabled(self): + """ Tests that the COURSE_CREATION_DISABLED flag overrides course creator group settings. """ + with mock.patch.dict('django.conf.settings.MITX_FEATURES', + {'DISABLE_COURSE_CREATION': True, "ENABLE_CREATOR_GROUP": True}): + # Add user to creator group. + self.assertTrue(add_user_to_creator_group(self.admin, self.user)) + + # DISABLE_COURSE_CREATION overrides (user is not marked as staff). + self.assertFalse(is_user_in_creator_group(self.user)) + + # Mark as staff. Now is_user_in_creator_group returns true. + self.user.is_staff = True + self.assertTrue(is_user_in_creator_group(self.user)) + + # Remove user from creator group. is_user_in_creator_group still returns true because is_staff=True + remove_user_from_creator_group(self.admin, self.user) + self.assertTrue(is_user_in_creator_group(self.user)) + + def test_add_user_to_group_requires_staff_access(self): + with self.assertRaises(PermissionDenied): + self.admin.is_staff = False + add_user_to_creator_group(self.admin, self.user) + + with self.assertRaises(PermissionDenied): + add_user_to_creator_group(self.user, self.user) + + def test_add_user_to_group_requires_active(self): + with self.assertRaises(PermissionDenied): + self.admin.is_active = False + add_user_to_creator_group(self.admin, self.user) + + def test_add_user_to_group_requires_authenticated(self): + with self.assertRaises(PermissionDenied): + self.admin.is_authenticated = False + add_user_to_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_staff_access(self): + with self.assertRaises(PermissionDenied): + self.admin.is_staff = False + remove_user_from_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_active(self): + with self.assertRaises(PermissionDenied): + self.admin.is_active = False + remove_user_from_creator_group(self.admin, self.user) + + def test_remove_user_from_group_requires_authenticated(self): + with self.assertRaises(PermissionDenied): + self.admin.is_authenticated = False + remove_user_from_creator_group(self.admin, self.user) + + +class CourseGroupTest(TestCase): + """ + Tests for instructor and staff groups for a particular course. + """ + + def setUp(self): + """ Test case setup """ + self.creator = User.objects.create_user('testcreator', 'testcreator+courses@edx.org', 'foo') + self.staff = User.objects.create_user('teststaff', 'teststaff+courses@edx.org', 'foo') + self.location = 'i4x', 'mitX', '101', 'course', 'test' + + def test_add_user_to_course_group(self): + """ + Tests adding user to course group (happy path). + """ + # Create groups for a new course (and assign instructor role to the creator). + self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + create_all_course_groups(self.creator, self.location) + self.assertTrue(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + + # Add another user to the staff role. + self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + def test_add_user_to_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of add_user_to_course_group is not instructor role. + """ + create_all_course_groups(self.creator, self.location) + with self.assertRaises(PermissionDenied): + add_user_to_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) + + def test_remove_user_from_course_group(self): + """ + Tests removing user from course group (happy path). + """ + create_all_course_groups(self.creator, self.location) + + self.assertTrue(add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME)) + self.assertTrue(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + remove_user_from_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME) + self.assertFalse(is_user_in_course_group_role(self.staff, self.location, STAFF_ROLE_NAME)) + + remove_user_from_course_group(self.creator, self.creator, self.location, INSTRUCTOR_ROLE_NAME) + self.assertFalse(is_user_in_course_group_role(self.creator, self.location, INSTRUCTOR_ROLE_NAME)) + + def test_remove_user_from_course_group_permission_denied(self): + """ + Verifies PermissionDenied if caller of remove_user_from_course_group is not instructor role. + """ + create_all_course_groups(self.creator, self.location) + with self.assertRaises(PermissionDenied): + remove_user_from_course_group(self.staff, self.staff, self.location, STAFF_ROLE_NAME) + + def test_get_staff(self): + # Do this test with staff in 2 different classes. + create_all_course_groups(self.creator, self.location) + add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME) + + location2 = 'i4x', 'mitX', '103', 'course2', 'test2' + staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo') + create_all_course_groups(self.creator, location2) + add_user_to_course_group(self.creator, staff2, location2, STAFF_ROLE_NAME) + + self.assertSetEqual({self.staff, staff2, self.creator}, get_users_with_staff_role()) + + def test_get_instructor(self): + # Do this test with creators in 2 different classes. + create_all_course_groups(self.creator, self.location) + add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME) + + location2 = 'i4x', 'mitX', '103', 'course2', 'test2' + creator2 = User.objects.create_user('testcreator2', 'testcreator2+courses@edx.org', 'foo') + staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo') + create_all_course_groups(creator2, location2) + add_user_to_course_group(creator2, staff2, location2, STAFF_ROLE_NAME) + + self.assertSetEqual({self.creator, creator2}, get_users_with_instructor_role()) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index ada3873992..7e1e6470ff 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -20,8 +20,8 @@ def get_course_updates(location): try: course_updates = modulestore('direct').get_item(location) except ItemNotFoundError: - template = Location(['i4x', 'edx', "templates", 'course_info', "Empty"]) - course_updates = modulestore('direct').clone_item(template, Location(location)) + modulestore('direct').create_and_save_xmodule(location) + course_updates = modulestore('direct').get_item(location) # current db rep: {"_id" : locationjson, "definition" : { "data" : "
    [
  1. date

    content
  2. ]
"} "metadata" : ignored} location_base = course_updates.location.url() diff --git a/cms/djangoapps/contentstore/debug_file_uploader.py b/cms/djangoapps/contentstore/debug_file_uploader.py new file mode 100644 index 0000000000..d783e16192 --- /dev/null +++ b/cms/djangoapps/contentstore/debug_file_uploader.py @@ -0,0 +1,22 @@ +from django.core.files.uploadhandler import FileUploadHandler +import time + + +class DebugFileUploader(FileUploadHandler): + def __init__(self, request=None): + super(DebugFileUploader, self).__init__(request) + self.count = 0 + + def receive_data_chunk(self, raw_data, start): + time.sleep(1) + self.count = self.count + len(raw_data) + fail_at = None + if 'fail_at' in self.request.GET: + fail_at = int(self.request.GET.get('fail_at')) + if fail_at and self.count > fail_at: + raise Exception('Triggered fail') + + return raw_data + + def file_complete(self, file_size): + return None diff --git a/cms/djangoapps/contentstore/features/advanced-settings.feature b/cms/djangoapps/contentstore/features/advanced-settings.feature index 13600f2086..514eb8898e 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.feature +++ b/cms/djangoapps/contentstore/features/advanced-settings.feature @@ -46,3 +46,9 @@ Feature: Advanced (manual) course policy Then it is displayed as a string And I reload the page Then it is displayed as a string + + Scenario: Confirmation is shown on save + Given I am on the Advanced Course Settings page in Studio + When I edit the value of a policy key + And I press the "Save" notification button + Then I see a confirmation that my changes have been saved diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 473fc20a68..18e179abdb 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,8 +2,8 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_false, assert_equal, assert_regexp_matches, assert_true -from common import type_in_codemirror +from nose.tools import assert_false, assert_equal, assert_regexp_matches +from common import type_in_codemirror, press_the_notification_button KEY_CSS = '.key input.policy-key' VALUE_CSS = 'textarea.json' @@ -25,18 +25,6 @@ def i_am_on_advanced_course_settings(step): step.given('I select the Advanced Settings') -@step(u'I press the "([^"]*)" notification button$') -def press_the_notification_button(step, name): - css = 'a.%s-button' % name.lower() - - # Save was clicked if either the save notification bar is gone, or we have a error notification - # overlaying it (expected in the case of typing Object into display_name). - save_clicked = lambda: world.is_css_not_present('.is-shown.wrapper-notification-warning') or\ - world.is_css_present('.is-shown.wrapper-notification-error') - - assert_true(world.css_click(css, success_condition=save_clicked), 'Save button not clicked after 5 attempts.') - - @step(u'I edit the value of a policy key$') def edit_the_value_of_a_policy_key(step): type_in_codemirror(get_index_of(DISPLAY_NAME_KEY), 'X') @@ -102,25 +90,25 @@ def the_policy_key_value_is_changed(step): ############# HELPERS ############### 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], world.css_find(VALUE_CSS)[index].value, "value is incorrect") + for key, value in zip(expected_keys, expected_values): + index = get_index_of(key) + assert_false(index == -1, "Could not find key: {key}".format(key=key)) + assert_equal(value, world.css_find(VALUE_CSS)[index].value, "value is incorrect") def get_index_of(expected_key): - for counter in range(len(world.css_find(KEY_CSS))): - # Sometimes get stale reference if I hold on to the array of elements - key = world.css_find(KEY_CSS)[counter].value + for i, element in enumerate(world.css_find(KEY_CSS)): + # Sometimes get stale reference if I hold on to the array of elements + key = world.css_value(KEY_CSS, index=i) if key == expected_key: - return counter + return i return -1 def get_display_name_value(): index = get_index_of(DISPLAY_NAME_KEY) - return world.css_find(VALUE_CSS)[index].value + return world.css_value(VALUE_CSS, index=index) def change_display_name_value(step, new_value): diff --git a/cms/djangoapps/contentstore/features/checklists.feature b/cms/djangoapps/contentstore/features/checklists.feature index 3767144c99..10db23c4fa 100644 --- a/cms/djangoapps/contentstore/features/checklists.feature +++ b/cms/djangoapps/contentstore/features/checklists.feature @@ -8,7 +8,7 @@ Feature: Course checklists Scenario: A course author can mark tasks as complete Given I have opened Checklists Then I can check and uncheck tasks in a checklist - And They are correctly selected after I reload the page + And They are correctly selected after reloading the page Scenario: A task can link to a location within Studio Given I have opened Checklists diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index 9552d35036..e8dcd755a3 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -45,7 +45,7 @@ def i_can_check_and_uncheck_tasks(step): verifyChecklist2Status(2, 7, 29) -@step('They are correctly selected after I reload the page$') +@step('They are correctly selected after reloading the page$') def tasks_correctly_selected_after_reload(step): reload_the_page(step) verifyChecklist2Status(2, 7, 29) @@ -61,7 +61,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_in('Course Outline', world.css_find('.outline .page-header')[0].text) + assert_in('Course Outline', world.css_text('.outline .page-header')) assert_equal(1, len(world.browser.windows)) @@ -115,7 +115,7 @@ def clickActionLink(checklist, task, actionText): # text will be empty initially, wait for it to populate def verify_action_link_text(driver): - return action_link.text == actionText + return world.css_text('#course-checklist' + str(checklist) + ' a', index=task) == actionText world.wait_for(verify_action_link_text) - action_link.click() + world.css_click('#course-checklist' + str(checklist) + ' a', index=task) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index d833c6f58e..d357c8ae96 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -3,7 +3,6 @@ from lettuce import world, step from nose.tools import assert_true -from nose.tools import assert_equal from auth.authz import get_user_by_email @@ -13,8 +12,11 @@ import time from logging import getLogger logger = getLogger(__name__) +from terrain.browser import reset_data + ########### 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 @@ -51,9 +53,52 @@ def i_have_opened_a_new_course(_step): open_new_course() +@step(u'I press the "([^"]*)" notification button$') +def press_the_notification_button(_step, name): + css = 'a.action-%s' % name.lower() + + # The button was clicked if either the notification bar is gone, + # or we see an error overlaying it (expected for invalid inputs). + def button_clicked(): + confirmation_dismissed = world.is_css_not_present('.is-shown.wrapper-notification-warning') + error_showing = world.is_css_present('.is-shown.wrapper-notification-error') + return confirmation_dismissed or error_showing + + world.css_click(css, success_condition=button_clicked), '%s button not clicked after 5 attempts.' % name + + +@step('I change the "(.*)" field to "(.*)"$') +def i_change_field_to_value(_step, field, value): + field_css = '#%s' % '-'.join([s.lower() for s in field.split()]) + ele = world.css_find(field_css).first + ele.fill(value) + ele._element.send_keys(Keys.ENTER) + + +@step('I reset the database') +def reset_the_db(_step): + """ + When running Lettuce tests using examples (i.e. "Confirmation is + shown on save" in course-settings.feature), the normal hooks + aren't called between examples. reset_data should run before each + scenario to flush the test database. When this doesn't happen we + get errors due to trying to insert a non-unique entry. So instead, + we delete the database manually. This has the effect of removing + any users and courses that have been created during the test run. + """ + reset_data(None) + + +@step('I see a confirmation that my changes have been saved') +def i_see_a_confirmation(step): + confirmation_css = '#alert-confirmation' + assert world.is_css_present(confirmation_css) + + ####### HELPER FUNCTIONS ############## def open_new_course(): world.clear_courses() + create_studio_user() log_into_studio() create_a_course() @@ -73,10 +118,11 @@ def create_studio_user( registration.register(studio_user) registration.activate() + def fill_in_course_info( name='Robot Super Course', org='MITx', - num='101'): + num='999'): world.css_fill('.new-course-name', name) world.css_fill('.new-course-org', org) world.css_fill('.new-course-number', num) @@ -85,10 +131,7 @@ def fill_in_course_info( def log_into_studio( uname='robot', email='robot+studio@edx.org', - password='test', - is_staff=False): - - create_studio_user(uname=uname, email=email, is_staff=is_staff) + password='test'): world.browser.cookies.delete() world.visit('/') @@ -97,23 +140,30 @@ def log_into_studio( 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() - + def fill_login_form(): + 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() + world.retry_on_exception(fill_login_form) assert_true(world.is_css_present('.new-course-button')) + world.scenario_dict['USER'] = get_user_by_email(email) def create_a_course(): - world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') + world.scenario_dict['COURSE'] = world.CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') # Add the user to the instructor group of the course # so they will have the permissions to see it in studio - g = world.GroupFactory.create(name='instructor_MITx/999/Robot_Super_Course') - u = get_user_by_email('robot+studio@edx.org') - u.groups.add(g) - u.save() + + course = world.GroupFactory.create(name='instructor_MITx/{}/{}'.format(world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(" ", "_"))) + if world.scenario_dict.get('USER') is None: + user = world.scenario_dict['USER'] + else: + user = get_user_by_email('robot+studio@edx.org') + user.groups.add(course) + user.save() world.browser.reload() course_link_css = 'span.class-name' @@ -158,8 +208,9 @@ def set_date_and_time(date_css, desired_date, time_css, desired_time): def i_created_a_video_component(step): world.create_component_instance( step, '.large-video-icon', - 'i4x://edx/templates/video/default', - '.xmodule_VideoModule' + 'video', + '.xmodule_VideoModule', + has_multiple_templates=False ) @@ -171,6 +222,34 @@ def open_new_unit(step): world.css_click('a.new-unit-item') +@step('when I view the video it (.*) show the captions') +def shows_captions(step, show_captions): + # Prevent cookies from overriding course settings + world.browser.cookies.delete('hide_captions') + if show_captions == 'does not': + assert world.css_has_class('.video', 'closed') + else: + assert world.is_css_not_present('.video.closed') + + +@step('the save button is disabled$') +def save_button_disabled(step): + button_css = '.action-save' + disabled = 'is-disabled' + assert world.css_has_class(button_css, disabled) + + +@step('I confirm the prompt') +def confirm_the_prompt(step): + prompt_css = 'a.button.action-primary' + world.css_click(prompt_css) + + +@step(u'I am shown a (.*)$') +def i_am_shown_a_notification(step, notification_type): + assert world.is_css_present('.wrapper-%s' % notification_type) + + def type_in_codemirror(index, text): world.css_click(".CodeMirror", index=index) g = world.css_find("div.CodeMirror.CodeMirror-focused > div > textarea") diff --git a/cms/djangoapps/contentstore/features/component.feature b/cms/djangoapps/contentstore/features/component.feature new file mode 100644 index 0000000000..a30ce96ae6 --- /dev/null +++ b/cms/djangoapps/contentstore/features/component.feature @@ -0,0 +1,87 @@ +Feature: Component Adding + As a course author, I want to be able to add a wide variety of components + + @skip + Scenario: I can add components + Given I have opened a new course in studio + And I am editing a new unit + When I add the following components: + | Component | + | Discussion | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + Then I see the following components: + | Component | + | Discussion | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + + @skip + Scenario: I can delete Components + Given I have opened a new course in studio + And I am editing a new unit + And I add the following components: + | Component | + | Discussion | + | Blank HTML | + | LaTex | + | Blank Problem| + | Dropdown | + | Multi Choice | + | Numerical | + | Text Input | + | Advanced | + | Circuit | + | Custom Python| + | Image Mapped | + | Math Input | + | Problem LaTex| + | Adaptive Hint| + | Video | + When I will confirm all alerts + And I delete all components + Then I see no components + + Scenario: I see a prompt on delete + Given I have opened a new course in studio + And I am editing a new unit + And I add the following components: + | Component | + | Discussion | + And I delete a component + Then I am shown a prompt + + Scenario: I see a notification on save + Given I have opened a new course in studio + And I am editing a new unit + And I add the following components: + | Component | + | Discussion | + And I edit and save a component + Then I am shown a notification diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py new file mode 100644 index 0000000000..15727dd992 --- /dev/null +++ b/cms/djangoapps/contentstore/features/component.py @@ -0,0 +1,137 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from nose.tools import assert_true + +DATA_LOCATION = 'i4x://edx/templates' + + +@step(u'I am editing a new unit') +def add_unit(step): + css_selectors = ['a.new-courseware-section-button', 'input.new-section-name-save', 'a.new-subsection-item', + 'input.new-subsection-name-save', 'div.section-item a.expand-collapse-icon', 'a.new-unit-item'] + for selector in css_selectors: + world.css_click(selector) + + +@step(u'I add the following components:') +def add_components(step): + for component in [step_hash['Component'] for step_hash in step.hashes]: + assert component in COMPONENT_DICTIONARY + for css in COMPONENT_DICTIONARY[component]['steps']: + world.css_click(css) + + +@step(u'I see the following components') +def check_components(step): + for component in [step_hash['Component'] for step_hash in step.hashes]: + assert component in COMPONENT_DICTIONARY + assert_true(COMPONENT_DICTIONARY[component]['found_func'](), "{} couldn't be found".format(component)) + + +@step(u'I delete all components') +def delete_all_components(step): + for _ in range(len(COMPONENT_DICTIONARY)): + world.css_click('a.delete-button') + + +@step(u'I see no components') +def see_no_components(steps): + assert world.is_css_not_present('li.component') + + +@step(u'I delete a component') +def delete_one_component(step): + world.css_click('a.delete-button') + + +@step(u'I edit and save a component') +def edit_and_save_component(step): + world.css_click('.edit-button') + world.css_click('.save-button') + + +def step_selector_list(data_type, path, index=1): + selector_list = ['a[data-type="{}"]'.format(data_type)] + if index != 1: + selector_list.append('a[id="ui-id-{}"]'.format(index)) + if path is not None: + selector_list.append('a[data-location="{}/{}/{}"]'.format(DATA_LOCATION, data_type, path)) + return selector_list + + +def found_text_func(text): + return lambda: world.browser.is_text_present(text) + + +def found_css_func(css): + return lambda: world.is_css_present(css, wait_time=2) + +COMPONENT_DICTIONARY = { + 'Discussion': { + 'steps': step_selector_list('discussion', None), + 'found_func': found_css_func('section.xmodule_DiscussionModule') + }, + 'Blank HTML': { + 'steps': step_selector_list('html', 'Blank_HTML_Page'), + #this one is a blank html so a more refined search is being done + 'found_func': lambda: '\n \n' in [x.html for x in world.css_find('section.xmodule_HtmlModule')] + }, + 'LaTex': { + 'steps': step_selector_list('html', 'E-text_Written_in_LaTeX'), + 'found_func': found_text_func('EXAMPLE: E-TEXT PAGE') + }, + 'Blank Problem': { + 'steps': step_selector_list('problem', 'Blank_Common_Problem'), + 'found_func': found_text_func('BLANK COMMON PROBLEM') + }, + 'Dropdown': { + 'steps': step_selector_list('problem', 'Dropdown'), + 'found_func': found_text_func('DROPDOWN') + }, + 'Multi Choice': { + 'steps': step_selector_list('problem', 'Multiple_Choice'), + 'found_func': found_text_func('MULTIPLE CHOICE') + }, + 'Numerical': { + 'steps': step_selector_list('problem', 'Numerical_Input'), + 'found_func': found_text_func('NUMERICAL INPUT') + }, + 'Text Input': { + 'steps': step_selector_list('problem', 'Text_Input'), + 'found_func': found_text_func('TEXT INPUT') + }, + 'Advanced': { + 'steps': step_selector_list('problem', 'Blank_Advanced_Problem', index=2), + 'found_func': found_text_func('BLANK ADVANCED PROBLEM') + }, + 'Circuit': { + 'steps': step_selector_list('problem', 'Circuit_Schematic_Builder', index=2), + 'found_func': found_text_func('CIRCUIT SCHEMATIC BUILDER') + }, + 'Custom Python': { + 'steps': step_selector_list('problem', 'Custom_Python-Evaluated_Input', index=2), + 'found_func': found_text_func('CUSTOM PYTHON-EVALUATED INPUT') + }, + 'Image Mapped': { + 'steps': step_selector_list('problem', 'Image_Mapped_Input', index=2), + 'found_func': found_text_func('IMAGE MAPPED INPUT') + }, + 'Math Input': { + 'steps': step_selector_list('problem', 'Math_Expression_Input', index=2), + 'found_func': found_text_func('MATH EXPRESSION INPUT') + }, + 'Problem LaTex': { + 'steps': step_selector_list('problem', 'Problem_Written_in_LaTeX', index=2), + 'found_func': found_text_func('PROBLEM WRITTEN IN LATEX') + }, + 'Adaptive Hint': { + 'steps': step_selector_list('problem', 'Problem_with_Adaptive_Hint', index=2), + 'found_func': found_text_func('PROBLEM WITH ADAPTIVE HINT') + }, + 'Video': { + 'steps': step_selector_list('video', None), + 'found_func': found_css_func('section.xmodule_VideoModule') + } +} diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 43164f62be..2b206e4466 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -7,10 +7,16 @@ from terrain.steps import reload_the_page @world.absorb -def create_component_instance(step, component_button_css, instance_id, expected_css): - click_new_component_button(step, component_button_css) - click_component_from_menu(instance_id, expected_css) +def create_component_instance(step, component_button_css, category, + expected_css, boilerplate=None, + has_multiple_templates=True): + click_new_component_button(step, component_button_css) + + if has_multiple_templates: + click_component_from_menu(category, boilerplate, expected_css) + + assert_equal(1, len(world.css_find(expected_css))) @world.absorb def click_new_component_button(step, component_button_css): @@ -19,7 +25,7 @@ def click_new_component_button(step, component_button_css): @world.absorb -def click_component_from_menu(instance_id, expected_css): +def click_component_from_menu(category, boilerplate, expected_css): """ Creates a component from `instance_id`. For components with more than one template, clicks on `elem_css` to create the new @@ -27,12 +33,13 @@ def click_component_from_menu(instance_id, expected_css): as the user clicks the appropriate button, so we assert that the expected component is present. """ - elem_css = "a[data-location='%s']" % instance_id + if boilerplate: + elem_css = "a[data-category='{}'][data-boilerplate='{}']".format(category, boilerplate) + else: + elem_css = "a[data-category='{}']:not([data-boilerplate])".format(category) elements = world.css_find(elem_css) - assert(len(elements) == 1) - if elements[0]['id'] == instance_id: # If this is a component with multiple templates - world.css_click(elem_css) - assert_equal(1, len(world.css_find(expected_css))) + assert_equal(len(elements), 1) + world.css_click(elem_css) @world.absorb diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature b/cms/djangoapps/contentstore/features/course-overview.feature similarity index 86% rename from cms/djangoapps/contentstore/features/studio-overview-togglesection.feature rename to cms/djangoapps/contentstore/features/course-overview.feature index e746f3629a..b3041b9b18 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.feature +++ b/cms/djangoapps/contentstore/features/course-overview.feature @@ -1,7 +1,7 @@ -Feature: Overview Toggle Section - In order to quickly view the details of a course's section or to scan the inventory of sections +Feature: Course Overview + In order to quickly view the details of a course's section and set release dates and grading As a course author - I want to toggle the visibility of each section's subsection details in the overview listing + I want to use the course overview page Scenario: The default layout for the overview page is to show sections in expanded view Given I have a course with multiple sections @@ -57,3 +57,9 @@ Feature: Overview Toggle Section And I click the "Expand All Sections" link Then I see the "Collapse All Sections" link And all sections are expanded + + Scenario: Notification is shown on grading status changes + Given I have a course with 1 section + When I navigate to the course overview page + And I change an assignment's grading status + Then I am shown a notification diff --git a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py b/cms/djangoapps/contentstore/features/course-overview.py similarity index 84% rename from cms/djangoapps/contentstore/features/studio-overview-togglesection.py rename to cms/djangoapps/contentstore/features/course-overview.py index 468099f417..10fa6453b2 100644 --- a/cms/djangoapps/contentstore/features/studio-overview-togglesection.py +++ b/cms/djangoapps/contentstore/features/course-overview.py @@ -22,7 +22,7 @@ def have_a_course_with_1_section(step): section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( parent_location=section.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection One',) @@ -33,24 +33,25 @@ def have_a_course_with_two_sections(step): section = world.ItemFactory.create(parent_location=course.location) subsection1 = world.ItemFactory.create( parent_location=section.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection One',) section2 = world.ItemFactory.create( parent_location=course.location, display_name='Section Two',) subsection2 = world.ItemFactory.create( parent_location=section2.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection Alpha',) subsection3 = world.ItemFactory.create( parent_location=section2.location, - template='i4x://edx/templates/sequential/Empty', + category='sequential', display_name='Subsection Beta',) @step(u'I navigate to the course overview page$') def navigate_to_the_course_overview_page(step): - log_into_studio(is_staff=True) + create_studio_user(is_staff=True) + log_into_studio() course_locator = '.class-name' world.css_click(course_locator) @@ -91,7 +92,7 @@ def i_expand_a_section(step): def i_see_the_span_with_text(step, text): span_locator = '.toggle-button-sections span' assert_true(world.is_css_present(span_locator)) - assert_equal(world.css_find(span_locator).value, text) + assert_equal(world.css_value(span_locator), text) assert_true(world.css_visible(span_locator)) @@ -107,13 +108,19 @@ def i_do_not_see_the_span_with_text(step, text): def all_sections_are_expanded(step): subsection_locator = 'div.subsection-list' subsections = world.css_find(subsection_locator) - for s in subsections: - assert_true(s.visible) + for index in range(len(subsections)): + assert_true(world.css_visible(subsection_locator, index=index)) @step(u'all sections are collapsed$') def all_sections_are_collapsed(step): subsection_locator = 'div.subsection-list' subsections = world.css_find(subsection_locator) - for s in subsections: - assert_false(s.visible) + for index in range(len(subsections)): + assert_false(world.css_visible(subsection_locator, index=index)) + + +@step(u"I change an assignment's grading status") +def change_grading_status(step): + world.css_find('a.menu-toggle').click() + world.css_find('.menu li').first.click() diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index e869bfe47a..5c79dc7ee3 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -5,15 +5,18 @@ Feature: Course Settings Given I have opened a new course in Studio When I select Schedule and Details And I set course dates + And I press the "Save" notification button Then I see the set dates on refresh Scenario: User can clear previously set course dates (except start date) Given I have set course dates And I clear all the dates except start + And I press the "Save" notification button Then I see cleared dates on refresh Scenario: User cannot clear the course start date Given I have set course dates + And I press the "Save" notification button And I clear the course start date Then I receive a warning about course start date And The previously set start date is shown on refresh @@ -21,5 +24,50 @@ Feature: Course Settings Scenario: User can correct the course start date warning Given I have tried to clear the course start And I have entered a new course start date + And I press the "Save" notification button Then The warning about course start date goes away And My new course start date is shown on refresh + + Scenario: Settings are only persisted when saved + Given I have set course dates + And I press the "Save" notification button + When I change fields + Then I do not see the new changes persisted on refresh + + Scenario: Settings are reset on cancel + Given I have set course dates + And I press the "Save" notification button + When I change fields + And I press the "Cancel" notification button + Then I do not see the changes + + Scenario: Confirmation is shown on save + Given I have opened a new course in Studio + When I select Schedule and Details + And I change the "" field to "" + And I press the "Save" notification button + Then I see a confirmation that my changes have been saved + # Lettuce hooks don't get called between each example, so we need + # to run the before.each_scenario hook manually to avoid database + # errors. + And I reset the database + + Examples: + | field | value | + | Course Start Time | 11:00 | + | Course Introduction Video | 4r7wHMg5Yjg | + | Course Effort | 200:00 | + + # Special case because we have to type in code mirror + Scenario: Changes in Course Overview show a confirmation + Given I have opened a new course in Studio + When I select Schedule and Details + And I change the course overview + And I press the "Save" notification button + Then I see a confirmation that my changes have been saved + + Scenario: User cannot save invalid settings + Given I have opened a new course in Studio + When I select Schedule and Details + And I change the "Course Start Date" field to "" + Then the save button is disabled diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index bd86fff9b7..da72d893cf 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -4,7 +4,7 @@ from lettuce import world, step from terrain.steps import reload_the_page from selenium.webdriver.common.keys import Keys -import time +from common import type_in_codemirror from nose.tools import assert_true, assert_false, assert_equal @@ -47,22 +47,11 @@ def test_and_i_set_course_dates(step): set_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) set_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) - pause() - @step('Then I see the set dates on refresh$') def test_then_i_see_the_set_dates_on_refresh(step): reload_the_page(step) - verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') - verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013') - verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013') - verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013') - - verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) - # Unset times get set to 12 AM once the corresponding date has been set. - verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME) - verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME) - verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) + i_see_the_set_dates() @step('And I clear all the dates except start$') @@ -71,8 +60,6 @@ def test_and_i_clear_all_the_dates_except_start(step): set_date_or_time(ENROLLMENT_START_DATE_CSS, '') set_date_or_time(ENROLLMENT_END_DATE_CSS, '') - pause() - @step('Then I see cleared dates on refresh$') def test_then_i_see_cleared_dates_on_refresh(step): @@ -119,7 +106,6 @@ def test_i_have_tried_to_clear_the_course_start(step): @step('I have entered a new course start date$') def test_i_have_entered_a_new_course_start_date(step): set_date_or_time(COURSE_START_DATE_CSS, '12/22/2013') - pause() @step('The warning about course start date goes away$') @@ -137,6 +123,30 @@ def test_my_new_course_start_date_is_shown_on_refresh(step): verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) +@step('I change fields$') +def test_i_change_fields(step): + set_date_or_time(COURSE_START_DATE_CSS, '7/7/7777') + set_date_or_time(COURSE_END_DATE_CSS, '7/7/7777') + set_date_or_time(ENROLLMENT_START_DATE_CSS, '7/7/7777') + set_date_or_time(ENROLLMENT_END_DATE_CSS, '7/7/7777') + + +@step('I do not see the new changes persisted on refresh$') +def test_changes_not_shown_on_refresh(step): + step.then('Then I see the set dates on refresh') + + +@step('I do not see the changes') +def test_i_do_not_see_changes(_step): + i_see_the_set_dates() + + +@step('I change the course overview') +def test_change_course_overview(_step): + type_in_codemirror(0, "

Overview

") + + + ############### HELPER METHODS #################### def set_date_or_time(css, date_or_time): """ @@ -152,12 +162,20 @@ def verify_date_or_time(css, date_or_time): """ Verifies date or time field. """ - assert_equal(date_or_time, world.css_find(css).first.value) + assert_equal(date_or_time, world.css_value(css)) -def pause(): +def i_see_the_set_dates(): """ - Must sleep briefly to allow last time save to finish, - else refresh of browser will fail. + Ensure that each field has the value set in `test_and_i_set_course_dates`. """ - time.sleep(float(1)) + verify_date_or_time(COURSE_START_DATE_CSS, '12/20/2013') + verify_date_or_time(COURSE_END_DATE_CSS, '12/26/2013') + verify_date_or_time(ENROLLMENT_START_DATE_CSS, '12/01/2013') + verify_date_or_time(ENROLLMENT_END_DATE_CSS, '12/10/2013') + + verify_date_or_time(COURSE_START_TIME_CSS, DUMMY_TIME) + # Unset times get set to 12 AM once the corresponding date has been set. + verify_date_or_time(COURSE_END_TIME_CSS, DEFAULT_TIME) + verify_date_or_time(ENROLLMENT_START_TIME_CSS, DEFAULT_TIME) + verify_date_or_time(ENROLLMENT_END_TIME_CSS, DUMMY_TIME) diff --git a/cms/djangoapps/contentstore/features/course-team.feature b/cms/djangoapps/contentstore/features/course-team.feature new file mode 100644 index 0000000000..fc1212f398 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-team.feature @@ -0,0 +1,34 @@ +Feature: Course Team + As a course author, I want to be able to add others to my team + + Scenario: Users can add other users + Given I have opened a new course in Studio + And the user "alice" exists + And I am viewing the course team settings + When I add "alice" to the course team + And "alice" logs in + Then she does see the course on her page + + Scenario: Added users cannot delete or add other users + Given I have opened a new course in Studio + And the user "bob" exists + And I am viewing the course team settings + When I add "bob" to the course team + And "bob" logs in + Then he cannot delete users + And he cannot add users + + Scenario: Users can delete other users + Given I have opened a new course in Studio + And the user "carol" exists + And I am viewing the course team settings + When I add "carol" to the course team + And I delete "carol" from the course team + And "carol" logs in + Then she does not see the course on her page + + Scenario: Users cannot add users that do not exist + Given I have opened a new course in Studio + And I am viewing the course team settings + When I add "dennis" to the course team + Then I should see "Could not find user by email address" somewhere on the page diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py new file mode 100644 index 0000000000..ad5d31977c --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -0,0 +1,67 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from common import create_studio_user, log_into_studio + +PASSWORD = 'test' +EMAIL_EXTENSION = '@edx.org' + + +@step(u'I am viewing the course team settings') +def view_grading_settings(_step): + world.click_course_settings() + link_css = 'li.nav-course-settings-team a' + world.css_click(link_css) + + +@step(u'the user "([^"]*)" exists$') +def create_other_user(_step, name): + create_studio_user(uname=name, password=PASSWORD, email=(name + EMAIL_EXTENSION)) + + +@step(u'I add "([^"]*)" to the course team') +def add_other_user(_step, name): + new_user_css = 'a.new-user-button' + world.css_click(new_user_css) + + email_css = 'input.email-input' + f = world.css_find(email_css) + f._element.send_keys(name, EMAIL_EXTENSION) + + confirm_css = '#add_user' + world.css_click(confirm_css) + + +@step(u'I delete "([^"]*)" from the course team') +def delete_other_user(_step, name): + to_delete_css = 'a.remove-user[data-id="{name}{extension}"]'.format(name=name, extension=EMAIL_EXTENSION) + world.css_click(to_delete_css) + + +@step(u'"([^"]*)" logs in$') +def other_user_login(_step, name): + log_into_studio(uname=name, password=PASSWORD, email=name + EMAIL_EXTENSION) + + +@step(u's?he does( not)? see the course on (his|her) page') +def see_course(_step, doesnt_see_course, gender): + class_css = 'span.class-name' + all_courses = world.css_find(class_css, wait_time=1) + all_names = [item.html for item in all_courses] + if doesnt_see_course: + assert not world.scenario_dict['COURSE'].display_name in all_names + else: + assert world.scenario_dict['COURSE'].display_name in all_names + + +@step(u's?he cannot delete users') +def cannot_delete(_step): + to_delete_css = 'a.remove-user' + assert world.is_css_not_present(to_delete_css) + + +@step(u's?he cannot add users') +def cannot_add(_step): + add_css = 'a.new-user' + assert world.is_css_not_present(add_css) diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature new file mode 100644 index 0000000000..81714c43ae --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -0,0 +1,37 @@ +Feature: Course updates + As a course author, I want to be able to provide updates to my students + + Scenario: Users can add updates + Given I have opened a new course in Studio + And I go to the course updates page + When I add a new update with the text "Hello" + Then I should see the update "Hello" + + Scenario: Users can edit updates + Given I have opened a new course in Studio + And I go to the course updates page + When I add a new update with the text "Hello" + And I modify the text to "Goodbye" + Then I should see the update "Goodbye" + + Scenario: Users can delete updates + Given I have opened a new course in Studio + And I go to the course updates page + And I add a new update with the text "Hello" + When I will confirm all alerts + And I delete the update + Then I should not see the update "Hello" + + + Scenario: Users can edit update dates + Given I have opened a new course in Studio + And I go to the course updates page + And I add a new update with the text "Hello" + When I edit the date to "June 1, 2013" + Then I should see the date "June 1, 2013" + + Scenario: Users can change handouts + Given I have opened a new course in Studio + And I go to the course updates page + When I modify the handout to "
    Test
" + Then I see the handout "Test" diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py new file mode 100644 index 0000000000..9506191a76 --- /dev/null +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -0,0 +1,82 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from selenium.webdriver.common.keys import Keys +from common import type_in_codemirror + + +@step(u'I go to the course updates page') +def go_to_updates(_step): + menu_css = 'li.nav-course-courseware' + updates_css = 'li.nav-course-courseware-updates' + world.css_click(menu_css) + world.css_click(updates_css) + + +@step(u'I add a new update with the text "([^"]*)"$') +def add_update(_step, text): + update_css = 'a.new-update-button' + world.css_click(update_css) + change_text(text) + + +@step(u'I should( not)? see the update "([^"]*)"$') +def check_update(_step, doesnt_see_update, text): + update_css = 'div.update-contents' + update = world.css_find(update_css, wait_time=1) + if doesnt_see_update: + assert len(update) == 0 or not text in update.html + else: + assert text in update.html + + +@step(u'I modify the text to "([^"]*)"$') +def modify_update(_step, text): + button_css = 'div.post-preview a.edit-button' + world.css_click(button_css) + change_text(text) + + +@step(u'I delete the update$') +def click_button(_step): + button_css = 'div.post-preview a.delete-button' + world.css_click(button_css) + + +@step(u'I edit the date to "([^"]*)"$') +def change_date(_step, new_date): + button_css = 'div.post-preview a.edit-button' + world.css_click(button_css) + date_css = 'input.date' + date = world.css_find(date_css) + for i in range(len(date.value)): + date._element.send_keys(Keys.END, Keys.BACK_SPACE) + date._element.send_keys(new_date) + save_css = 'a.save-button' + world.css_click(save_css) + + +@step(u'I should see the date "([^"]*)"$') +def check_date(_step, date): + date_css = 'span.date-display' + assert date == world.css_html(date_css) + + +@step(u'I modify the handout to "([^"]*)"$') +def edit_handouts(_step, text): + edit_css = 'div.course-handouts > a.edit-button' + world.css_click(edit_css) + change_text(text) + + +@step(u'I see the handout "([^"]*)"$') +def check_handout(_step, handout): + handout_css = 'div.handouts-content' + assert handout in world.css_html(handout_css) + + +def change_text(text): + type_in_codemirror(0, text) + save_css = 'a.save-button' + world.css_click(save_css) diff --git a/cms/djangoapps/contentstore/features/courses.py b/cms/djangoapps/contentstore/features/courses.py index a3e838a9d1..2feafce361 100644 --- a/cms/djangoapps/contentstore/features/courses.py +++ b/cms/djangoapps/contentstore/features/courses.py @@ -10,6 +10,7 @@ from common import * @step('There are no courses$') def no_courses(step): world.clear_courses() + create_studio_user() @step('I click the New Course button$') @@ -44,7 +45,7 @@ def courseware_page_has_loaded_in_studio(step): @step('I see the course listed in My Courses$') def i_see_the_course_in_my_courses(step): course_css = 'span.class-name' - assert world.css_has_text(course_css, 'Robot Super Course') + assert world.css_has_text(course_css, world.scenario_dict['COURSE'].display_name) @step('I am on the "([^"]*)" tab$') diff --git a/cms/djangoapps/contentstore/features/discussion-editor.py b/cms/djangoapps/contentstore/features/discussion-editor.py index ae3da3c458..15a7c4b9ab 100644 --- a/cms/djangoapps/contentstore/features/discussion-editor.py +++ b/cms/djangoapps/contentstore/features/discussion-editor.py @@ -8,8 +8,9 @@ from lettuce import world, step def i_created_discussion_tag(step): world.create_component_instance( step, '.large-discussion-icon', - 'i4x://edx/templates/discussion/Discussion_Tag', - '.xmodule_DiscussionModule' + 'discussion', + '.xmodule_DiscussionModule', + has_multiple_templates=False ) @@ -17,14 +18,14 @@ def i_created_discussion_tag(step): def i_see_only_the_settings_and_values(step): world.verify_all_setting_entries( [ - ['Category', "Week 1", True], - ['Display Name', "Discussion Tag", True], - ['Subcategory', "Topic-Level Student-Visible Label", True] + ['Category', "Week 1", False], + ['Display Name', "Discussion", False], + ['Subcategory', "Topic-Level Student-Visible Label", False] ]) @step('creating a discussion takes a single click') def discussion_takes_a_single_click(step): assert(not world.is_css_present('.xmodule_DiscussionModule')) - world.css_click("a[data-location='i4x://edx/templates/discussion/Discussion_Tag']") + world.css_click("a[data-category='discussion']") assert(world.is_css_present('.xmodule_DiscussionModule')) diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature index 78634cb964..b01d762d73 100644 --- a/cms/djangoapps/contentstore/features/grading.feature +++ b/cms/djangoapps/contentstore/features/grading.feature @@ -32,6 +32,7 @@ Feature: Course Grading And I have populated the course And I am viewing the grading settings When I change assignment type "Homework" to "New Type" + And I press the "Save" notification button And I go back to the main course page Then I do see the assignment name "New Type" And I do not see the assignment name "Homework" @@ -41,6 +42,7 @@ Feature: Course Grading And I have populated the course And I am viewing the grading settings When I delete the assignment type "Homework" + And I press the "Save" notification button And I go back to the main course page Then I do not see the assignment name "Homework" @@ -49,5 +51,36 @@ Feature: Course Grading And I have populated the course And I am viewing the grading settings When I add a new assignment type "New Type" + And I press the "Save" notification button And I go back to the main course page Then I do see the assignment name "New Type" + + Scenario: Settings are only persisted when saved + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I change assignment type "Homework" to "New Type" + Then I do not see the changes persisted on refresh + + Scenario: Settings are reset on cancel + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I change assignment type "Homework" to "New Type" + And I press the "Cancel" notification button + Then I see the assignment type "Homework" + + Scenario: Confirmation is shown on save + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I change assignment type "Homework" to "New Type" + And I press the "Save" notification button + Then I see a confirmation that my changes have been saved + + Scenario: User cannot save invalid settings + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + When I change assignment type "Homework" to "" + Then the save button is disabled diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py index 4e59897c1c..0b60510bf5 100644 --- a/cms/djangoapps/contentstore/features/grading.py +++ b/cms/djangoapps/contentstore/features/grading.py @@ -3,6 +3,7 @@ from lettuce import world, step from common import * +from terrain.steps import reload_the_page @step(u'I am viewing the grading settings') @@ -47,7 +48,7 @@ def confirm_change(step): range_css = '.range' all_ranges = world.css_find(range_css) for i in range(len(all_ranges)): - assert all_ranges[i].html != '0-50' + assert world.css_html(range_css, index=i) != '0-50' @step(u'I change assignment type "([^"]*)" to "([^"]*)"$') @@ -63,7 +64,9 @@ def change_assignment_name(step, old_name, new_name): @step(u'I go back to the main course page') def main_course_page(step): - main_page_link_css = 'a[href="/MITx/999/course/Robot_Super_Course"]' + main_page_link_css = 'a[href="/%s/%s/course/%s"]' % (world.scenario_dict['COURSE'].org, + world.scenario_dict['COURSE'].number, + world.scenario_dict['COURSE'].display_name.replace(' ', '_'),) world.css_click(main_page_link_css) @@ -89,8 +92,8 @@ def add_assignment_type(step, new_name): add_button_css = '.add-grading-data' world.css_click(add_button_css) name_id = '#course-grading-assignment-name' - f = world.css_find(name_id)[4] - f._element.send_keys(new_name) + new_assignment = world.css_find(name_id)[-1] + new_assignment._element.send_keys(new_name) @step(u'I have populated the course') @@ -99,10 +102,25 @@ def populate_course(step): step.given('I have added a new subsection') +@step(u'I do not see the changes persisted on refresh$') +def changes_not_persisted(step): + reload_the_page(step) + name_id = '#course-grading-assignment-name' + assert(world.css_value(name_id) == 'Homework') + + +@step(u'I see the assignment type "(.*)"$') +def i_see_the_assignment_type(_step, name): + assignment_css = '#course-grading-assignment-name' + assignments = world.css_find(assignment_css) + types = [ele['value'] for ele in assignments] + assert name in types + + def get_type_index(name): name_id = '#course-grading-assignment-name' - f = world.css_find(name_id) - for i in range(len(f)): - if f[i].value == name: - return i + all_types = world.css_find(name_id) + for index in range(len(all_types)): + if world.css_value(name_id, index=index) == name: + return index return -1 diff --git a/cms/djangoapps/contentstore/features/html-editor.py b/cms/djangoapps/contentstore/features/html-editor.py index 054c0ea642..c3e0afa480 100644 --- a/cms/djangoapps/contentstore/features/html-editor.py +++ b/cms/djangoapps/contentstore/features/html-editor.py @@ -7,11 +7,11 @@ from lettuce import world, step @step('I have created a Blank HTML Page$') def i_created_blank_html_page(step): world.create_component_instance( - step, '.large-html-icon', 'i4x://edx/templates/html/Blank_HTML_Page', + step, '.large-html-icon', 'html', '.xmodule_HtmlModule' ) @step('I see only the HTML display name setting$') def i_see_only_the_html_display_name(step): - world.verify_all_setting_entries([['Display Name', "Blank HTML Page", True]]) + world.verify_all_setting_entries([['Display Name', "Text", False]]) diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index 8691a6772e..565a35f802 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -18,8 +18,9 @@ def i_created_blank_common_problem(step): world.create_component_instance( step, '.large-problem-icon', - 'i4x://edx/templates/problem/Blank_Common_Problem', - '.xmodule_CapaModule' + 'problem', + '.xmodule_CapaModule', + 'blank_common.yaml' ) @@ -35,8 +36,8 @@ def i_see_five_settings_with_values(step): [DISPLAY_NAME, "Blank Common Problem", True], [MAXIMUM_ATTEMPTS, "", False], [PROBLEM_WEIGHT, "", False], - [RANDOMIZATION, "Never", True], - [SHOW_ANSWER, "Finished", True] + [RANDOMIZATION, "Never", False], + [SHOW_ANSWER, "Finished", False] ]) @@ -94,7 +95,7 @@ def my_change_to_randomization_is_persisted(step): def i_can_revert_to_default_for_randomization(step): world.revert_setting_entry(RANDOMIZATION) world.save_component_and_reopen(step) - world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Always", False) + world.verify_setting_entry(world.get_setting_entry(RANDOMIZATION), RANDOMIZATION, "Never", False) @step('I can set the weight to "(.*)"?') @@ -156,7 +157,7 @@ def create_latex_problem(step): world.click_new_component_button(step, '.large-problem-icon') # Go to advanced tab. world.css_click('#ui-id-2') - world.click_component_from_menu("i4x://edx/templates/problem/Problem_Written_in_LaTeX", '.xmodule_CapaModule') + world.click_component_from_menu("problem", "latex_problem.yaml", '.xmodule_CapaModule') @step('I edit and compile the High Level Source') @@ -169,7 +170,8 @@ def edit_latex_source(step): @step('my change to the High Level Source is persisted') def high_level_source_persisted(step): def verify_text(driver): - return world.css_find('.problem').text == 'hi' + css_sel = '.problem div>span' + return world.css_text(css_sel) == 'hi' world.wait_for(verify_text) @@ -177,7 +179,7 @@ def high_level_source_persisted(step): @step('I view the High Level Source I see my changes') def high_level_source_in_editor(step): open_high_level_source() - assert_equal('hi', world.css_find('.source-edit-box').value) + assert_equal('hi', world.css_value('.source-edit-box')) def verify_high_level_source_links(step, visible): @@ -203,7 +205,7 @@ def verify_modified_display_name_with_special_chars(): def verify_unset_display_name(): - world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, '', False) + world.verify_setting_entry(world.get_setting_entry(DISPLAY_NAME), DISPLAY_NAME, 'Blank Advanced Problem', False) def set_weight(weight): diff --git a/cms/djangoapps/contentstore/features/section.feature b/cms/djangoapps/contentstore/features/section.feature index 80ccb6cc7a..a08b490c6d 100644 --- a/cms/djangoapps/contentstore/features/section.feature +++ b/cms/djangoapps/contentstore/features/section.feature @@ -3,6 +3,7 @@ Feature: Create Section As a course author I want to create and edit sections + @skip Scenario: Add a new section to a course Given I have opened a new course in Studio When I click the New Section link @@ -25,10 +26,12 @@ Feature: Create Section When I click the Edit link for the release date And I save a new section release date Then the section release date is updated + And I see a "saving" notification Scenario: Delete section Given I have opened a new course in Studio And I have added a new section When I will confirm all alerts And I press the "section" delete icon + And I confirm the prompt Then the section does not exist diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 989c73e010..4b69b9b37e 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -42,6 +42,12 @@ def i_save_a_new_section_release_date(_step): world.browser.click_link_by_text('Save') +@step('I see a "saving" notification') +def i_see_a_saving_notification(step): + saving_css = '.wrapper-notification-mini' + assert world.is_css_present(saving_css) + + ############ ASSERTIONS ################### @@ -64,7 +70,7 @@ def i_click_to_edit_section_name(_step): def i_see_complete_section_name_with_quote_in_editor(_step): css = '.section-name-edit input[type=text]' assert world.is_css_present(css) - assert_equal(world.browser.find_by_css(css).value, 'Section with "Quote"') + assert_equal(world.css_value(css), 'Section with "Quote"') @step('the section does not exist$') @@ -79,7 +85,7 @@ def i_see_a_release_date_for_my_section(_step): css = 'span.published-status' assert world.is_css_present(css) - status_text = world.browser.find_by_css(css).text + status_text = world.css_text(css) # e.g. 11/06/2012 at 16:25 msg = 'Will Release:' diff --git a/cms/djangoapps/contentstore/features/signup.feature b/cms/djangoapps/contentstore/features/signup.feature index 03a1c9524a..01c912deca 100644 --- a/cms/djangoapps/contentstore/features/signup.feature +++ b/cms/djangoapps/contentstore/features/signup.feature @@ -9,4 +9,21 @@ Feature: Sign in And I fill in the registration form And I press the Create My Account button on the registration form Then I should see be on the studio home page - And I should see the message "please click on the activation link in your email." + And I should see the message "complete your sign up we need you to verify your email address" + + Scenario: Login with a valid redirect + Given I have opened a new course in Studio + And I am not logged in + And I visit the url "/MITx/999/course/Robot_Super_Course" + And I should see that the path is "/signin?next=/MITx/999/course/Robot_Super_Course" + When I fill in and submit the signin form + And I wait for "2" seconds + Then I should see that the path is "/MITx/999/course/Robot_Super_Course" + + Scenario: Login with an invalid redirect + Given I have opened a new course in Studio + And I am not logged in + And I visit the url "/signin?next=http://www.google.com/" + When I fill in and submit the signin form + And I wait for "2" seconds + Then I should see that the path is "/" diff --git a/cms/djangoapps/contentstore/features/signup.py b/cms/djangoapps/contentstore/features/signup.py index 398f8d074d..e9abb55a78 100644 --- a/cms/djangoapps/contentstore/features/signup.py +++ b/cms/djangoapps/contentstore/features/signup.py @@ -2,17 +2,18 @@ #pylint: disable=W0621 from lettuce import world, step -from common import * @step('I fill in the registration form$') def i_fill_in_the_registration_form(step): - register_form = world.browser.find_by_css('form#register_form') - register_form.find_by_name('email').fill('robot+studio@edx.org') - register_form.find_by_name('password').fill('test') - register_form.find_by_name('username').fill('robot-studio') - register_form.find_by_name('name').fill('Robot Studio') - register_form.find_by_name('terms_of_service').check() + def fill_in_reg_form(): + register_form = world.css_find('form#register_form') + register_form.find_by_name('email').fill('robot+studio@edx.org') + register_form.find_by_name('password').fill('test') + register_form.find_by_name('username').fill('robot-studio') + register_form.find_by_name('name').fill('Robot Studio') + register_form.find_by_name('terms_of_service').check() + world.retry_on_exception(fill_in_reg_form) @step('I press the Create My Account button on the registration form$') @@ -23,9 +24,19 @@ def i_press_the_button_on_the_registration_form(step): @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') + step.given('I should see the message "My Courses"') @step(u'I should see the message "([^"]*)"$') def i_should_see_the_message(step, msg): assert world.browser.is_text_present(msg, 5) + + +@step(u'I fill in and submit the signin form$') +def i_fill_in_the_signin_form(step): + def fill_login_form(): + login_form = world.browser.find_by_css('form#login_form') + login_form.find_by_name('email').fill('robot+studio@edx.org') + login_form.find_by_name('password').fill('test') + login_form.find_by_name('submit').click() + world.retry_on_exception(fill_login_form) diff --git a/cms/djangoapps/contentstore/features/static-pages.feature b/cms/djangoapps/contentstore/features/static-pages.feature new file mode 100644 index 0000000000..9997df69f0 --- /dev/null +++ b/cms/djangoapps/contentstore/features/static-pages.feature @@ -0,0 +1,24 @@ +Feature: Static Pages + As a course author, I want to be able to add static pages + + Scenario: Users can add static pages + Given I have opened a new course in Studio + And I go to the static pages page + When I add a new page + Then I should see a "Empty" static page + + Scenario: Users can delete static pages + Given I have opened a new course in Studio + And I go to the static pages page + And I add a new page + When I will confirm all alerts + And I "delete" the "Empty" page + Then I should not see a "Empty" static page + + Scenario: Users can edit static pages + Given I have opened a new course in Studio + And I go to the static pages page + And I add a new page + When I "edit" the "Empty" page + And I change the name to "New" + Then I should see a "New" static page diff --git a/cms/djangoapps/contentstore/features/static-pages.py b/cms/djangoapps/contentstore/features/static-pages.py new file mode 100644 index 0000000000..3c9226f874 --- /dev/null +++ b/cms/djangoapps/contentstore/features/static-pages.py @@ -0,0 +1,59 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from selenium.webdriver.common.keys import Keys + + +@step(u'I go to the static pages page') +def go_to_static(_step): + menu_css = 'li.nav-course-courseware' + static_css = 'li.nav-course-courseware-pages' + world.css_click(menu_css) + world.css_click(static_css) + + +@step(u'I add a new page') +def add_page(_step): + button_css = 'a.new-button' + world.css_click(button_css) + + +@step(u'I should( not)? see a "([^"]*)" static page$') +def see_page(_step, doesnt, page): + index = get_index(page) + if doesnt: + assert index == -1 + else: + assert index != -1 + + +@step(u'I "([^"]*)" the "([^"]*)" page$') +def click_edit_delete(_step, edit_delete, page): + button_css = 'a.%s-button' % edit_delete + index = get_index(page) + assert index != -1 + world.css_click(button_css, index=index) + + +@step(u'I change the name to "([^"]*)"$') +def change_name(_step, new_name): + settings_css = '#settings-mode' + world.css_click(settings_css) + input_css = 'input.setting-input' + name_input = world.css_find(input_css) + old_name = name_input.value + for count in range(len(old_name)): + name_input._element.send_keys(Keys.END, Keys.BACK_SPACE) + name_input._element.send_keys(new_name) + save_button = 'a.save-button' + world.css_click(save_button) + + +def get_index(name): + page_name_css = 'section[data-type="HTMLModule"]' + all_pages = world.css_find(page_name_css) + for i in range(len(all_pages)): + if world.css_html(page_name_css, index=i) == '\n {name}\n'.format(name=name): + return i + return -1 diff --git a/cms/djangoapps/contentstore/features/subsection.feature b/cms/djangoapps/contentstore/features/subsection.feature index a11467e3f9..9f5793dbe7 100644 --- a/cms/djangoapps/contentstore/features/subsection.feature +++ b/cms/djangoapps/contentstore/features/subsection.feature @@ -38,4 +38,5 @@ Feature: Create Subsection And I see my subsection on the Courseware page When I will confirm all alerts And I press the "subsection" delete icon + And I confirm the prompt Then the subsection does not exist diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 1134e53280..e280ec615d 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -50,7 +50,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.css_find(css).value, 'Subsection With "Quote"') + assert_equal(world.css_value(css), 'Subsection With "Quote"') @step('I have set a release date and due date in different years$') @@ -69,7 +69,7 @@ def i_mark_it_as_homework(step): @step('I see it marked as Homework$') def i_see_it_marked__as_homework(step): - assert_equal(world.css_find(".status-label").value, 'Homework') + assert_equal(world.css_value(".status-label"), 'Homework') ############ ASSERTIONS ################### diff --git a/cms/djangoapps/contentstore/features/textbooks.feature b/cms/djangoapps/contentstore/features/textbooks.feature new file mode 100644 index 0000000000..0758a0b57b --- /dev/null +++ b/cms/djangoapps/contentstore/features/textbooks.feature @@ -0,0 +1,47 @@ +Feature: Textbooks + + Scenario: No textbooks + Given I have opened a new course in Studio + When I go to the textbooks page + Then I should see a message telling me to create a new textbook + + Scenario: Create a textbook + Given I have opened a new course in Studio + And I go to the textbooks page + When I click on the New Textbook button + And I name my textbook "Economics" + And I name the first chapter "Chapter 1" + And I click the Upload Asset link for the first chapter + And I upload the textbook "textbook.pdf" + And I wait for "2" seconds + And I save the textbook + Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf" + And I reload the page + Then I should see a textbook named "Economics" with a chapter path containing "/c4x/MITx/999/asset/textbook.pdf" + + Scenario: Create a textbook with multiple chapters + Given I have opened a new course in Studio + And I go to the textbooks page + When I click on the New Textbook button + And I name my textbook "History" + And I name the first chapter "Britain" + And I type in "britain.pdf" for the first chapter asset + And I click Add a Chapter + And I name the second chapter "America" + And I type in "america.pdf" for the second chapter asset + And I save the textbook + Then I should see a textbook named "History" with 2 chapters + And I click the textbook chapters + Then I should see a textbook named "History" with 2 chapters + And the first chapter should be named "Britain" + And the first chapter should have an asset called "britain.pdf" + And the second chapter should be named "America" + And the second chapter should have an asset called "america.pdf" + And I reload the page + Then I should see a textbook named "History" with 2 chapters + And I click the textbook chapters + Then I should see a textbook named "History" with 2 chapters + And the first chapter should be named "Britain" + And the first chapter should have an asset called "britain.pdf" + And the second chapter should be named "America" + And the second chapter should have an asset called "america.pdf" diff --git a/cms/djangoapps/contentstore/features/textbooks.py b/cms/djangoapps/contentstore/features/textbooks.py new file mode 100644 index 0000000000..ca135d9725 --- /dev/null +++ b/cms/djangoapps/contentstore/features/textbooks.py @@ -0,0 +1,121 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from django.conf import settings +import os + +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT + + +@step(u'I go to the textbooks page') +def go_to_uploads(_step): + world.click_course_content() + menu_css = 'li.nav-course-courseware-textbooks' + world.css_find(menu_css).click() + + +@step(u'I should see a message telling me to create a new textbook') +def assert_create_new_textbook_msg(_step): + css = ".wrapper-content .no-textbook-content" + assert world.is_css_present(css) + no_tb = world.css_find(css) + assert "You haven't added any textbooks" in no_tb.text + + +@step(u'I upload the textbook "([^"]*)"$') +def upload_file(_step, file_name): + file_css = '.upload-dialog input[type=file]' + upload = world.css_find(file_css) + # uploading the file itself + path = os.path.join(TEST_ROOT, 'uploads', file_name) + upload._element.send_keys(os.path.abspath(path)) + button_css = ".upload-dialog .action-upload" + world.css_click(button_css) + + +@step(u'I click (on )?the New Textbook button') +def click_new_textbook(_step, on): + button_css = ".nav-actions .new-button" + button = world.css_find(button_css) + button.click() + + +@step(u'I name my textbook "([^"]*)"') +def name_textbook(_step, name): + input_css = ".textbook input[name=textbook-name]" + world.css_fill(input_css, name) + + +@step(u'I name the (first|second|third) chapter "([^"]*)"') +def name_chapter(_step, ordinal, name): + index = ["first", "second", "third"].index(ordinal) + input_css = ".textbook .chapter{i} input.chapter-name".format(i=index+1) + world.css_fill(input_css, name) + + +@step(u'I type in "([^"]*)" for the (first|second|third) chapter asset') +def asset_chapter(_step, name, ordinal): + index = ["first", "second", "third"].index(ordinal) + input_css = ".textbook .chapter{i} input.chapter-asset-path".format(i=index+1) + world.css_fill(input_css, name) + + +@step(u'I click the Upload Asset link for the (first|second|third) chapter') +def click_upload_asset(_step, ordinal): + index = ["first", "second", "third"].index(ordinal) + button_css = ".textbook .chapter{i} .action-upload".format(i=index+1) + world.css_click(button_css) + + +@step(u'I click Add a Chapter') +def click_add_chapter(_step): + button_css = ".textbook .action-add-chapter" + world.css_click(button_css) + + +@step(u'I save the textbook') +def save_textbook(_step): + submit_css = "form.edit-textbook button[type=submit]" + world.css_click(submit_css) + + +@step(u'I should see a textbook named "([^"]*)" with a chapter path containing "([^"]*)"') +def check_textbook(_step, textbook_name, chapter_name): + title = world.css_find(".textbook h3.textbook-title") + chapter = world.css_find(".textbook .wrap-textbook p") + assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name) + assert chapter.text == chapter_name, "{} != {}".format(chapter.text, chapter_name) + + +@step(u'I should see a textbook named "([^"]*)" with (\d+) chapters') +def check_textbook_chapters(_step, textbook_name, num_chapters_str): + num_chapters = int(num_chapters_str) + title = world.css_find(".textbook .view-textbook h3.textbook-title") + toggle = world.css_find(".textbook .view-textbook .chapter-toggle") + assert title.text == textbook_name, "{} != {}".format(title.text, textbook_name) + assert toggle.text == "{num} PDF Chapters".format(num=num_chapters), \ + "Expected {num} chapters, found {real}".format(num=num_chapters, real=toggle.text) + + +@step(u'I click the textbook chapters') +def click_chapters(_step): + world.css_click(".textbook a.chapter-toggle") + + +@step(u'the (first|second|third) chapter should be named "([^"]*)"') +def check_chapter_name(_step, ordinal, name): + index = ["first", "second", "third"].index(ordinal) + chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index] + element = chapter.find_by_css(".chapter-name") + assert element.text == name, "Expected chapter named {expected}, found chapter named {actual}".format( + expected=name, actual=element.text) + + +@step(u'the (first|second|third) chapter should have an asset called "([^"]*)"') +def check_chapter_asset(_step, ordinal, name): + index = ["first", "second", "third"].index(ordinal) + chapter = world.css_find(".textbook .view-textbook ol.chapters li")[index] + element = chapter.find_by_css(".chapter-asset-path") + assert element.text == name, "Expected chapter with asset {expected}, found chapter with asset {actual}".format( + expected=name, actual=element.text) diff --git a/cms/djangoapps/contentstore/features/upload.feature b/cms/djangoapps/contentstore/features/upload.feature new file mode 100644 index 0000000000..8d40163685 --- /dev/null +++ b/cms/djangoapps/contentstore/features/upload.feature @@ -0,0 +1,39 @@ +Feature: Upload Files + As a course author, I want to be able to upload files for my students + + Scenario: Users can upload files + Given I have opened a new course in Studio + And I go to the files and uploads page + When I upload the file "test" + Then I should see the file "test" was uploaded + And The url for the file "test" is valid + + Scenario: Users can update files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I upload the file "test" + Then I should see only one "test" + + Scenario: Users can delete uploaded files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I delete the file "test" + Then I should not see the file "test" was uploaded + And I see a confirmation that the file was deleted + + Scenario: Users can download files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + Then I can download the correct "test" file + + Scenario: Users can download updated files + Given I have opened a new course in studio + And I go to the files and uploads page + When I upload the file "test" + And I modify "test" + And I reload the page + And I upload the file "test" + Then I can download the correct "test" file diff --git a/cms/djangoapps/contentstore/features/upload.py b/cms/djangoapps/contentstore/features/upload.py new file mode 100644 index 0000000000..0c700956e3 --- /dev/null +++ b/cms/djangoapps/contentstore/features/upload.py @@ -0,0 +1,114 @@ +#pylint: disable=C0111 +#pylint: disable=W0621 + +from lettuce import world, step +from django.conf import settings +import requests +import string +import random +import os + +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT +HTTP_PREFIX = "http://localhost:%s" % settings.LETTUCE_SERVER_PORT + + +@step(u'I go to the files and uploads page') +def go_to_uploads(_step): + menu_css = 'li.nav-course-courseware' + uploads_css = 'li.nav-course-courseware-uploads' + world.css_click(menu_css) + world.css_click(uploads_css) + + +@step(u'I upload the file "([^"]*)"$') +def upload_file(_step, file_name): + upload_css = 'a.upload-button' + world.css_click(upload_css) + + file_css = 'input.file-input' + upload = world.css_find(file_css) + #uploading the file itself + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + upload._element.send_keys(os.path.abspath(path)) + + close_css = 'a.close-button' + world.css_click(close_css) + + +@step(u'I should( not)? see the file "([^"]*)" was uploaded$') +def check_upload(_step, do_not_see_file, file_name): + index = get_index(file_name) + if do_not_see_file: + assert index == -1 + else: + assert index != -1 + + +@step(u'The url for the file "([^"]*)" is valid$') +def check_url(_step, file_name): + r = get_file(file_name) + assert r.status_code == 200 + + +@step(u'I delete the file "([^"]*)"$') +def delete_file(_step, file_name): + index = get_index(file_name) + assert index != -1 + delete_css = "a.remove-asset-button" + world.css_click(delete_css, index=index) + + prompt_confirm_css = 'li.nav-item > a.action-primary' + world.css_click(prompt_confirm_css) + + +@step(u'I should see only one "([^"]*)"$') +def no_duplicate(_step, file_name): + names_css = 'td.name-col > a.filename' + all_names = world.css_find(names_css) + only_one = False + for i in range(len(all_names)): + if file_name == world.css_html(names_css, index=i): + only_one = not only_one + assert only_one + + +@step(u'I can download the correct "([^"]*)" file$') +def check_download(_step, file_name): + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + with open(os.path.abspath(path), 'r') as cur_file: + cur_text = cur_file.read() + r = get_file(file_name) + downloaded_text = r.text + assert cur_text == downloaded_text + + +@step(u'I modify "([^"]*)"$') +def modify_upload(_step, file_name): + new_text = ''.join(random.choice(string.ascii_uppercase + string.digits) for x in range(10)) + path = os.path.join(TEST_ROOT, 'uploads/', file_name) + with open(os.path.abspath(path), 'w') as cur_file: + cur_file.write(new_text) + + +@step('I see a confirmation that the file was deleted') +def i_see_a_delete_confirmation(_step): + alert_css = '#notification-confirmation' + assert world.is_css_present(alert_css) + + +def get_index(file_name): + names_css = 'td.name-col > a.filename' + all_names = world.css_find(names_css) + for i in range(len(all_names)): + if file_name == world.css_html(names_css, index=i): + return i + return -1 + + +def get_file(file_name): + index = get_index(file_name) + assert index != -1 + + url_css = 'input.embeddable-xml-input' + url = world.css_find(url_css)[index].value + return requests.get(HTTP_PREFIX + url) diff --git a/cms/djangoapps/contentstore/features/video-editor.feature b/cms/djangoapps/contentstore/features/video-editor.feature index 4c2a460042..f28ee568dc 100644 --- a/cms/djangoapps/contentstore/features/video-editor.feature +++ b/cms/djangoapps/contentstore/features/video-editor.feature @@ -4,10 +4,20 @@ Feature: Video Component Editor Scenario: User can view metadata Given I have created a Video component And I edit and select Settings - Then I see only the Video display name setting + Then I see the correct settings and default values Scenario: User can modify display name Given I have created a Video component And I edit and select Settings Then I can modify the display name And my display name change is persisted on save + + Scenario: Captions are hidden when "show captions" is false + Given I have created a Video component + And I have set "show captions" to False + Then when I view the video it does not show the captions + + Scenario: Captions are shown when "show captions" is true + Given I have created a Video component + And I have set "show captions" to True + Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/video-editor.py b/cms/djangoapps/contentstore/features/video-editor.py index 27423575c3..93d638e621 100644 --- a/cms/djangoapps/contentstore/features/video-editor.py +++ b/cms/djangoapps/contentstore/features/video-editor.py @@ -1,9 +1,23 @@ # disable missing docstring -#pylint: disable=C0111 +# pylint: disable=C0111 from lettuce import world, step -@step('I see only the video display name setting$') -def i_see_only_the_video_display_name(step): - world.verify_all_setting_entries([['Display Name', "default", True]]) +@step('I see the correct settings and default values$') +def i_see_the_correct_settings_and_values(step): + world.verify_all_setting_entries([['Default Speed', 'OEoXaMPEzfM', False], + ['Display Name', 'Video', False], + ['Download Track', '', False], + ['Download Video', '', False], + ['Show Captions', 'True', False], + ['Speed: .75x', '', False], + ['Speed: 1.25x', '', False], + ['Speed: 1.5x', '', False]]) + + +@step('I have set "show captions" to (.*)') +def set_show_captions(step, setting): + world.css_click('a.edit-button') + world.browser.select('Show Captions', setting) + world.css_click('a.save-button') diff --git a/cms/djangoapps/contentstore/features/video.feature b/cms/djangoapps/contentstore/features/video.feature index 0129732d30..e4caa70ef6 100644 --- a/cms/djangoapps/contentstore/features/video.feature +++ b/cms/djangoapps/contentstore/features/video.feature @@ -9,7 +9,16 @@ Feature: Video Component Given I have clicked the new unit button Then creating a video takes a single click - Scenario: Captions are shown correctly + Scenario: Captions are hidden correctly Given I have created a Video component And I have hidden captions Then when I view the video it does not show the captions + + Scenario: Captions are shown correctly + Given I have created a Video component + Then when I view the video it does show the captions + + Scenario: Captions are toggled correctly + Given I have created a Video component + And I have toggled captions + Then when I view the video it does show the captions diff --git a/cms/djangoapps/contentstore/features/video.py b/cms/djangoapps/contentstore/features/video.py index c48b36a5aa..a6a362befc 100644 --- a/cms/djangoapps/contentstore/features/video.py +++ b/cms/djangoapps/contentstore/features/video.py @@ -8,21 +8,26 @@ from lettuce import world, step @step('when I view the video it does not have autoplay enabled') def does_not_autoplay(_step): assert world.css_find('.video')[0]['data-autoplay'] == 'False' - assert world.css_find('.video_control')[0].has_class('play') + assert world.css_has_class('.video_control', 'play') @step('creating a video takes a single click') def video_takes_a_single_click(_step): assert(not world.is_css_present('.xmodule_VideoModule')) - world.css_click("a[data-location='i4x://edx/templates/video/default']") + world.css_click("a[data-category='video']") assert(world.is_css_present('.xmodule_VideoModule')) -@step('I have hidden captions') -def set_show_captions_false(step): - world.css_click('a.hide-subtitles') - - -@step('when I view the video it does not show the captions') -def does_not_show_captions(step): - assert world.css_find('.video')[0].has_class('closed') +@step('I have (hidden|toggled) captions') +def hide_or_show_captions(step, shown): + button_css = 'a.hide-subtitles' + if shown == 'hidden': + world.css_click(button_css) + if shown == 'toggled': + world.css_click(button_css) + # When we click the first time, a tooltip shows up. We want to + # click the button rather than the tooltip, so move the mouse + # away to make it disappear. + button = world.css_find(button_css) + button.mouse_out() + world.css_click(button_css) diff --git a/cms/djangoapps/contentstore/management/commands/dump_course_structure.py b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py new file mode 100644 index 0000000000..d9b7c55cbd --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/dump_course_structure.py @@ -0,0 +1,55 @@ +from django.core.management.base import BaseCommand, CommandError +from xmodule.course_module import CourseDescriptor +from xmodule.modulestore.django import modulestore +from json import dumps +from xmodule.modulestore.inheritance import own_metadata +from django.conf import settings + +filter_list = ['xml_attributes', 'checklists'] + + +class Command(BaseCommand): + help = '''Write out to stdout a structural and metadata information about a course in a flat dictionary serialized + in a JSON format. This can be used for analytics.''' + + def handle(self, *args, **options): + if len(args) < 2 or len(args) > 3: + raise CommandError("dump_course_structure requires two or more arguments: ||") + + course_id = args[0] + outfile = args[1] + + # use a user-specified database name, if present + # this is useful for doing dumps from databases restored from prod backups + if len(args) == 3: + settings.MODULESTORE['direct']['OPTIONS']['db'] = args[2] + + loc = CourseDescriptor.id_to_location(course_id) + + store = modulestore() + + course = None + try: + course = store.get_item(loc, depth=4) + except: + print 'Could not find course at {0}'.format(course_id) + return + + info = {} + + def dump_into_dict(module, info): + filtered_metadata = dict((key, value) for key, value in own_metadata(module).iteritems() + if key not in filter_list) + info[module.location.url()] = { + 'category': module.location.category, + 'children': module.children if hasattr(module, 'children') else [], + 'metadata': filtered_metadata + } + + for child in module.get_children(): + dump_into_dict(child, info) + + dump_into_dict(course, info) + + with open(outfile, 'w') as f: + f.write(dumps(info)) diff --git a/cms/djangoapps/contentstore/management/commands/export.py b/cms/djangoapps/contentstore/management/commands/export.py index eb7800d46c..90db8750d9 100644 --- a/cms/djangoapps/contentstore/management/commands/export.py +++ b/cms/djangoapps/contentstore/management/commands/export.py @@ -14,11 +14,11 @@ unnamed_modules = 0 class Command(BaseCommand): - help = 'Import the specified data directory into the default ModuleStore' + help = 'Export the specified data directory into the default ModuleStore' def handle(self, *args, **options): if len(args) != 2: - raise CommandError("import requires two arguments: ") + raise CommandError("export requires two arguments: ") course_id = args[0] output_path = args[1] @@ -30,4 +30,4 @@ class Command(BaseCommand): root_dir = os.path.dirname(output_path) course_dir = os.path.splitext(os.path.basename(output_path))[0] - export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir) + export_to_xml(modulestore('direct'), contentstore(), location, root_dir, course_dir, modulestore()) diff --git a/cms/djangoapps/contentstore/management/commands/export_all_courses.py b/cms/djangoapps/contentstore/management/commands/export_all_courses.py new file mode 100644 index 0000000000..69cfb298fb --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/export_all_courses.py @@ -0,0 +1,47 @@ +### +### Script for exporting all courseware from Mongo to a directory +### +import os + +from django.core.management.base import BaseCommand, CommandError +from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.django import modulestore +from xmodule.contentstore.django import contentstore +from xmodule.course_module import CourseDescriptor + + +unnamed_modules = 0 + + +class Command(BaseCommand): + help = 'Export all courses from mongo to the specified data directory' + + def handle(self, *args, **options): + if len(args) != 1: + raise CommandError("export requires one argument: ") + + output_path = args[0] + + cs = contentstore() + ms = modulestore('direct') + root_dir = output_path + courses = ms.get_courses() + + print "%d courses to export:" % len(courses) + cids = [x.id for x in courses] + print cids + + for course_id in cids: + + print "-"*77 + print "Exporting course id = {0} to {1}".format(course_id, output_path) + + if 1: + try: + location = CourseDescriptor.id_to_location(course_id) + course_dir = course_id.replace('/', '...') + export_to_xml(ms, cs, location, root_dir, course_dir, modulestore()) + except Exception as err: + print "="*30 + "> Oops, failed to export %s" % course_id + print "Error:" + print err diff --git a/cms/djangoapps/contentstore/management/commands/populate_creators.py b/cms/djangoapps/contentstore/management/commands/populate_creators.py new file mode 100644 index 0000000000..37d647fd1a --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/populate_creators.py @@ -0,0 +1,48 @@ +""" +Script for granting existing course instructors course creator privileges. + +This script is only intended to be run once on a given environment. +""" +from auth.authz import get_users_with_instructor_role, get_users_with_staff_role +from course_creators.views import add_user_with_status_granted, add_user_with_status_unrequested +from django.core.management.base import BaseCommand + +from django.contrib.auth.models import User +from django.db.utils import IntegrityError + + +class Command(BaseCommand): + """ + Script for granting existing course instructors course creator privileges. + """ + help = 'Grants all users with INSTRUCTOR role permission to create courses' + + def handle(self, *args, **options): + """ + The logic of the command. + """ + username = 'populate_creators_command' + email = 'grant+creator+access@edx.org' + try: + admin = User.objects.create_user(username, email, 'foo') + admin.is_staff = True + admin.save() + except IntegrityError: + # If the script did not complete the last time it was run, + # the admin user will already exist. + admin = User.objects.get(username=username, email=email) + + for user in get_users_with_instructor_role(): + add_user_with_status_granted(admin, user) + + # Some users will be both staff and instructors. Those folks have been + # added with status granted above, and add_user_with_status_unrequested + # will not try to add them again if they already exist in the course creator database. + for user in get_users_with_staff_role(): + add_user_with_status_unrequested(user) + + # There could be users who are not in either staff or instructor (they've + # never actually done anything in Studio). I plan to add those as unrequested + # when they first go to their dashboard. + + admin.delete() diff --git a/cms/djangoapps/contentstore/management/commands/update_templates.py b/cms/djangoapps/contentstore/management/commands/update_templates.py deleted file mode 100644 index 36348314b9..0000000000 --- a/cms/djangoapps/contentstore/management/commands/update_templates.py +++ /dev/null @@ -1,10 +0,0 @@ -from xmodule.templates import update_templates -from xmodule.modulestore.django import modulestore -from django.core.management.base import BaseCommand - - -class Command(BaseCommand): - help = 'Imports and updates the Studio component templates from the code pack and put in the DB' - - def handle(self, *args, **options): - update_templates(modulestore('direct')) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index e361c97875..bce4b0326c 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -3,16 +3,13 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location -def get_module_info(store, location, parent_location=None, rewrite_static_links=False): +def get_module_info(store, location, rewrite_static_links=False): try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) + module = store.get_item(location) except ItemNotFoundError: # create a new one - template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) - module = store.clone_item(template_location, location) + store.create_and_save_xmodule(location) + module = store.get_item(location) data = module.data if rewrite_static_links: @@ -32,7 +29,8 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= 'id': module.location.url(), 'data': data, # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata - 'metadata': module._model_data._kvs._metadata + # what's the intent here? all metadata incl inherited & namespaced? + 'metadata': module.xblock_kvs._metadata } @@ -40,14 +38,11 @@ def set_module_info(store, location, post_data): module = None try: module = store.get_item(location) - except: - pass - - if module is None: - # new module at this location - # presume that we have an 'Empty' template - template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) - module = store.clone_item(template_location, location) + except ItemNotFoundError: + # new module at this location: almost always used for the course about pages; thus, no parent. (there + # are quite a handful of about page types available for a course and only the overview is pre-created) + store.create_and_save_xmodule(location) + module = store.get_item(location) if post_data.get('data') is not None: data = post_data['data'] @@ -82,4 +77,4 @@ def set_module_info(store, location, post_data): # commit to datastore # TODO (cpennington): This really shouldn't have to do this much reaching in to get the metadata - store.update_metadata(location, module._model_data._kvs._metadata) + store.update_metadata(location, module.xblock_kvs._metadata) diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py new file mode 100644 index 0000000000..58aee3c77d --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -0,0 +1,95 @@ +""" +Unit tests for the asset upload endpoint. +""" + +import json +from datetime import datetime +from io import BytesIO +from pytz import UTC +from unittest import TestCase, skip +from .utils import CourseTestCase +from django.core.urlresolvers import reverse +from contentstore.views import assets + + +class AssetsTestCase(CourseTestCase): + def setUp(self): + super(AssetsTestCase, self).setUp() + self.url = reverse("asset_index", kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + }) + + def test_basic(self): + resp = self.client.get(self.url) + self.assertEquals(resp.status_code, 200) + + def test_json(self): + resp = self.client.get( + self.url, + HTTP_ACCEPT="application/json", + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + self.assertEquals(resp.status_code, 200) + content = json.loads(resp.content) + self.assertIsInstance(content, list) + + +class UploadTestCase(CourseTestCase): + """ + Unit tests for uploading a file + """ + def setUp(self): + super(UploadTestCase, self).setUp() + self.url = reverse("upload_asset", kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'coursename': self.course.location.name, + }) + + @skip("CorruptGridFile error on continuous integration server") + def test_happy_path(self): + file = BytesIO("sample content") + file.name = "sample.txt" + resp = self.client.post(self.url, {"name": "my-name", "file": file}) + self.assert2XX(resp.status_code) + + def test_no_file(self): + resp = self.client.post(self.url, {"name": "file.txt"}) + self.assert4XX(resp.status_code) + + def test_get(self): + resp = self.client.get(self.url) + self.assertEquals(resp.status_code, 405) + + +class AssetsToJsonTestCase(TestCase): + """ + Unit tests for transforming the results of a database call into something + we can send out to the client via JSON. + """ + def test_basic(self): + upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC) + asset = { + "displayname": "foo", + "chunkSize": 512, + "filename": "foo.png", + "length": 100, + "uploadDate": upload_date, + "_id": { + "course": "course", + "org": "org", + "revision": 12, + "category": "category", + "name": "name", + "tag": "tag", + } + } + output = assets.assets_to_json_dict([asset]) + self.assertEquals(len(output), 1) + compare = output[0] + self.assertEquals(compare["name"], "foo") + self.assertEquals(compare["path"], "foo.png") + self.assertEquals(compare["uploaded"], upload_date.isoformat()) + self.assertEquals(compare["id"], "/tag/org/course/12/category/name") diff --git a/cms/djangoapps/contentstore/tests/test_checklists.py b/cms/djangoapps/contentstore/tests/test_checklists.py index 0e5cd9b884..02999f6567 100644 --- a/cms/djangoapps/contentstore/tests/test_checklists.py +++ b/cms/djangoapps/contentstore/tests/test_checklists.py @@ -1,10 +1,10 @@ """ Unit tests for checklist methods in views.py. """ from contentstore.utils import get_modulestore, get_url_reverse -from contentstore.tests.test_course_settings import CourseTestCase from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.tests.factories import CourseFactory from django.core.urlresolvers import reverse import json +from .utils import CourseTestCase class ChecklistTestCase(CourseTestCase): @@ -19,7 +19,6 @@ class ChecklistTestCase(CourseTestCase): modulestore = get_modulestore(self.course.location) return modulestore.get_item(self.course.location).checklists - def compare_checklists(self, persisted, request): """ Handles url expansion as possible difference and descends into guts @@ -47,6 +46,8 @@ class ChecklistTestCase(CourseTestCase): # Now delete the checklists from the course and verify they get repopulated (for courses # created before checklists were introduced). self.course.checklists = None + # Save the changed `checklists` to the underlying KeyValueStore before updating the modulestore + self.course.save() modulestore = get_modulestore(self.course.location) modulestore.update_metadata(self.course.location, own_metadata(self.course)) self.assertEqual(self.get_persisted_checklists(), None) @@ -99,7 +100,6 @@ class ChecklistTestCase(CourseTestCase): 'name': self.course.location.name, 'checklist_index': 2}) - def get_first_item(checklist): return checklist['items'][0] @@ -119,4 +119,4 @@ class ChecklistTestCase(CourseTestCase): 'name': self.course.location.name, 'checklist_index': 100}) response = self.client.delete(update_url) - self.assertContains(response, 'Unsupported request', status_code=400) + self.assertEqual(response.status_code, 405) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index d24deacecf..b15c05b984 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,5 +1,8 @@ +#pylint: disable=E1101 + import json import shutil +import mock from django.test.client import Client from django.test.utils import override_settings from django.conf import settings @@ -16,15 +19,16 @@ from django.dispatch import Signal from contentstore.utils import get_modulestore from contentstore.tests.utils import parse_json +from auth.authz import add_user_to_creator_group + from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory -from xmodule.modulestore import Location +from xmodule.modulestore import Location, mongo from xmodule.modulestore.store_utilities import clone_course from xmodule.modulestore.store_utilities import delete_course from xmodule.modulestore.django import modulestore -from xmodule.contentstore.django import contentstore -from xmodule.templates import update_templates +from xmodule.contentstore.django import contentstore, _CONTENTSTORE from xmodule.modulestore.xml_exporter import export_to_xml from xmodule.modulestore.xml_importer import import_from_xml, perform_xlint from xmodule.modulestore.inheritance import own_metadata @@ -43,10 +47,12 @@ from django_comment_common.utils import are_permissions_roles_seeded from xmodule.exceptions import InvalidVersionError import datetime from pytz import UTC +from uuid import uuid4 +from pymongo import MongoClient -TEST_DATA_MODULESTORE = copy.deepcopy(settings.MODULESTORE) -TEST_DATA_MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') -TEST_DATA_MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') + +TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) +TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex class MongoCollectionFindWrapper(object): @@ -59,13 +65,16 @@ class MongoCollectionFindWrapper(object): return self.original(query, *args, **kwargs) -@override_settings(MODULESTORE=TEST_DATA_MODULESTORE) +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ Tests that rely on the toy courses. TODO: refactor using CourseFactory so they do not. """ def setUp(self): + + settings.MODULESTORE['default']['OPTIONS']['fs_root'] = path('common/test/data') + settings.MODULESTORE['direct']['OPTIONS']['fs_root'] = path('common/test/data') uname = 'testuser' email = 'test+courses@edx.org' password = 'foo' @@ -78,11 +87,18 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.user.is_active = True # Staff has access to view all courses self.user.is_staff = True + + # Save the data that we've just changed to the db. self.user.save() self.client = Client() self.client.login(username=uname, password=password) + def tearDown(self): + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() + def check_components_on_page(self, component_types, expected_types): """ Ensure that the right types end up on the page. @@ -103,6 +119,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): course.advanced_modules = component_types + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course.save() + store.update_metadata(course.location, own_metadata(course)) # just pick one vertical @@ -120,7 +140,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.check_components_on_page(ADVANCED_COMPONENT_TYPES, ['Video Alpha', 'Word cloud', 'Annotation', - 'Open Ended Response', + 'Open Response Assessment', 'Peer Grading Interface']) def test_advanced_components_require_two_clicks(self): @@ -149,9 +169,6 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_edit_unit_toy(self): self.check_edit_unit('toy') - def test_edit_unit_full(self): - self.check_edit_unit('full') - def _get_draft_counts(self, item): cnt = 1 if getattr(item, 'is_draft', False) else 0 for child in item.get_children(): @@ -171,7 +188,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) # now query get_items() to get this location with revision=None, this should just # return back a single item (not 2) @@ -203,7 +220,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(html_module.lms.graceperiod, course.lms.graceperiod) self.assertNotIn('graceperiod', own_metadata(html_module)) - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) # refetch to check metadata html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) @@ -221,13 +238,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertNotIn('graceperiod', own_metadata(html_module)) # put back in draft and change metadata and see if it's now marked as 'own_metadata' - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) new_graceperiod = timedelta(hours=1) self.assertNotIn('graceperiod', own_metadata(html_module)) html_module.lms.graceperiod = new_graceperiod + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + html_module.save() self.assertIn('graceperiod', own_metadata(html_module)) self.assertEqual(html_module.lms.graceperiod, new_graceperiod) @@ -243,7 +263,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): draft_store.publish(html_module.location, 0) # and re-read and verify 'own-metadata' - draft_store.clone_item(html_module.location, html_module.location) + draft_store.convert_to_draft(html_module.location) html_module = draft_store.get_item(['i4x', 'edX', 'simple', 'html', 'test_html', None]) self.assertIn('graceperiod', own_metadata(html_module)) @@ -266,7 +286,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ) # put into draft - modulestore('draft').clone_item(problem.location, problem.location) + modulestore('draft').convert_to_draft(problem.location) # make sure we can query that item and verify that it is a draft draft_problem = modulestore('draft').get_item( @@ -286,41 +306,68 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): def test_import_textbook_as_content_element(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) + import_from_xml(module_store, 'common/test/data/', ['toy']) - course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None])) self.assertGreater(len(course.textbooks), 0) + def test_default_tabs_on_create_course(self): + module_store = modulestore('direct') + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) + + course = module_store.get_item(course_location) + + expected_tabs = [] + expected_tabs.append({u'type': u'courseware'}) + expected_tabs.append({u'type': u'course_info', u'name': u'Course Info'}) + expected_tabs.append({u'type': u'textbooks'}) + expected_tabs.append({u'type': u'discussion', u'name': u'Discussion'}) + expected_tabs.append({u'type': u'wiki', u'name': u'Wiki'}) + expected_tabs.append({u'type': u'progress', u'name': u'Progress'}) + + self.assertEqual(course.tabs, expected_tabs) + def test_static_tab_reordering(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) - course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + ItemFactory.create( + parent_location=course_location, + category="static_tab", + display_name="Static_1") + ItemFactory.create( + parent_location=course_location, + category="static_tab", + display_name="Static_2") + + course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])) # reverse the ordering reverse_tabs = [] for tab in course.tabs: if tab['type'] == 'static_tab': - reverse_tabs.insert(0, 'i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) + reverse_tabs.insert(0, 'i4x://edX/999/static_tab/{0}'.format(tab['url_slug'])) self.client.post(reverse('reorder_static_tabs'), json.dumps({'tabs': reverse_tabs}), "application/json") - course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + course = module_store.get_item(Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None])) # compare to make sure that the tabs information is in the expected order after the server call course_tabs = [] for tab in course.tabs: if tab['type'] == 'static_tab': - course_tabs.append('i4x://edX/full/static_tab/{0}'.format(tab['url_slug'])) + course_tabs.append('i4x://edX/999/static_tab/{0}'.format(tab['url_slug'])) self.assertEqual(reverse_tabs, course_tabs) def test_import_polls(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) + import_from_xml(module_store, 'common/test/data/', ['toy']) - items = module_store.get_items(['i4x', 'edX', 'full', 'poll_question', None, None]) + items = module_store.get_items(['i4x', 'edX', 'toy', 'poll_question', None, None]) found = len(items) > 0 self.assertTrue(found) @@ -328,16 +375,33 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertGreater(len(items[0].question), 0) def test_xlint_fails(self): - err_cnt = perform_xlint('common/test/data', ['full']) + err_cnt = perform_xlint('common/test/data', ['toy']) self.assertGreater(err_cnt, 0) + @override_settings(COURSES_WITH_UNSAFE_CODE=['edX/toy/.*']) + def test_module_preview_in_whitelist(self): + ''' + Tests the ajax callback to render an XModule + ''' + direct_store = modulestore('direct') + import_from_xml(direct_store, 'common/test/data/', ['toy']) + + # also try a custom response which will trigger the 'is this course in whitelist' logic + problem_module_location = Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]) + url = reverse('preview_component', kwargs={'location': problem_module_location.url()}) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + def test_delete(self): direct_store = modulestore('direct') - import_from_xml(direct_store, 'common/test/data/', ['full']) + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) - sequential = direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + chapterloc = ItemFactory.create(parent_location=course_location, display_name="Chapter").location + ItemFactory.create(parent_location=chapterloc, category='sequential', display_name="Sequential") - chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) + sequential = direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None])) + chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None])) # make sure the parent points to the child object which is to be deleted self.assertTrue(sequential.location.url() in chapter.children) @@ -350,14 +414,14 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): found = False try: - direct_store.get_item(Location(['i4x', 'edX', 'full', 'sequential', 'Administrivia_and_Circuit_Elements', None])) + direct_store.get_item(Location(['i4x', 'edX', '999', 'sequential', 'Sequential', None])) found = True except ItemNotFoundError: pass self.assertFalse(found) - chapter = direct_store.get_item(Location(['i4x', 'edX', 'full', 'chapter', 'Week_1', None])) + chapter = direct_store.get_item(Location(['i4x', 'edX', '999', 'chapter', 'Chapter', None])) # make sure the parent no longer points to the child object which was deleted self.assertFalse(sequential.location.url() in chapter.children) @@ -368,20 +432,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): while there is a base definition in /about/effort.html ''' module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) - effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'effort', None])) + import_from_xml(module_store, 'common/test/data/', ['toy']) + effort = module_store.get_item(Location(['i4x', 'edX', 'toy', 'about', 'effort', None])) self.assertEqual(effort.data, '6 hours') # this one should be in a non-override folder - effort = module_store.get_item(Location(['i4x', 'edX', 'full', 'about', 'end_date', None])) + effort = module_store.get_item(Location(['i4x', 'edX', 'toy', 'about', 'end_date', None])) self.assertEqual(effort.data, 'TBD') def test_remove_hide_progress_tab(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) - - source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - course = module_store.get_item(source_location) + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) + course = module_store.get_item(course_location) self.assertFalse(course.hide_progress_tab) def test_asset_import(self): @@ -391,9 +454,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): content_store = contentstore() module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store) - course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + course_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') course = module_store.get_item(course_location) self.assertIsNotNone(course) @@ -403,7 +466,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertGreater(len(all_assets), 0) # make sure we have some thumbnails in our contentstore - all_thumbnails = content_store.get_all_content_thumbnails_for_course(course_location) + content_store.get_all_content_thumbnails_for_course(course_location) # # cdodge: temporarily comment out assertion on thumbnails because many environments @@ -414,7 +477,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): content = None try: - location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt') content = content_store.find(location) except NotFoundError: pass @@ -442,11 +505,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): content_store = contentstore() trash_store = contentstore('trashcan') module_store = modulestore('direct') - - import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store) # look up original (and thumbnail) in content store, should be there after import - location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt') content = content_store.find(location, throw_on_not_found=False) thumbnail_location = content.thumbnail_location self.assertIsNotNone(content) @@ -459,11 +521,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # go through the website to do the delete, since the soft-delete logic is in the view - url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'}) - resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'}) + url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'}) + resp = self.client.post(url, {'location': '/c4x/edX/toy/asset/sample_static.txt'}) self.assertEqual(resp.status_code, 200) - asset_location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + asset_location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt') # now try to find it in store, but they should not be there any longer content = content_store.find(asset_location, throw_on_not_found=False) @@ -482,7 +544,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertIsNotNone(thumbnail) # let's restore the asset - restore_asset_from_trashcan('/c4x/edX/full/asset/circuits_duality.gif') + restore_asset_from_trashcan('/c4x/edX/toy/asset/sample_static.txt') # now try to find it in courseware store, and they should be back after restore content = content_store.find(asset_location, throw_on_not_found=False) @@ -500,18 +562,18 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): trash_store = contentstore('trashcan') module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full'], static_content_store=content_store) + import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store) - course_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + course_location = CourseDescriptor.id_to_location('edX/toy/6.002_Spring_2012') - location = StaticContent.get_location_from_path('/c4x/edX/full/asset/circuits_duality.gif') + location = StaticContent.get_location_from_path('/c4x/edX/toy/asset/sample_static.txt') content = content_store.find(location, throw_on_not_found=False) self.assertIsNotNone(content) # go through the website to do the delete, since the soft-delete logic is in the view - url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'full', 'name': '6.002_Spring_2012'}) - resp = self.client.post(url, {'location': '/c4x/edX/full/asset/circuits_duality.gif'}) + url = reverse('remove_asset', kwargs={'org': 'edX', 'course': 'toy', 'name': '2012_Fall'}) + resp = self.client.post(url, {'location': '/c4x/edX/toy/asset/sample_static.txt'}) self.assertEqual(resp.status_code, 200) # make sure there's something in the trashcan @@ -519,7 +581,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertGreater(len(all_assets), 0) # make sure we have some thumbnails in our trashcan - all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) + _all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) # # cdodge: temporarily comment out assertion on thumbnails because many environments # will not have the jpeg converter installed and this test will fail @@ -533,21 +595,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): all_assets = trash_store.get_all_content_for_course(course_location) self.assertEqual(len(all_assets), 0) - all_thumbnails = trash_store.get_all_content_thumbnails_for_course(course_location) self.assertEqual(len(all_thumbnails), 0) def test_clone_course(self): course_data = { - 'template': 'i4x://edx/templates/course/Empty', 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', } module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) + import_from_xml(module_store, 'common/test/data/', ['toy']) resp = self.client.post(reverse('create_new_course'), course_data) self.assertEqual(resp.status_code, 200) @@ -556,16 +616,16 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): content_store = contentstore() - source_location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + source_location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') dest_location = CourseDescriptor.id_to_location('MITx/999/Robot_Super_Course') clone_course(module_store, content_store, source_location, dest_location) # now loop through all the units in the course and verify that the clone can render them, which # means the objects are at least present - items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + items = module_store.get_items(Location(['i4x', 'edX', 'toy', 'poll_question', None])) self.assertGreater(len(items), 0) - clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'vertical', None])) + clone_items = module_store.get_items(Location(['i4x', 'MITx', '999', 'poll_question', None])) self.assertGreater(len(clone_items), 0) for descriptor in items: new_loc = descriptor.location.replace(org='MITx', course='999') @@ -580,42 +640,60 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') location = Location('i4x://MITx/999/chapter/neuvo') - self.assertRaises(InvalidVersionError, draft_store.clone_item, 'i4x://edx/templates/chapter/Empty', - location) - direct_store.clone_item('i4x://edx/templates/chapter/Empty', location) - self.assertRaises(InvalidVersionError, draft_store.clone_item, location, - location) + # Ensure draft mongo store does not allow us to create chapters either directly or via convert to draft + self.assertRaises(InvalidVersionError, draft_store.create_and_save_xmodule, location) + direct_store.create_and_save_xmodule(location) + self.assertRaises(InvalidVersionError, draft_store.convert_to_draft, location) - self.assertRaises(InvalidVersionError, draft_store.update_item, location, - 'chapter data') + self.assertRaises(InvalidVersionError, draft_store.update_item, location, 'chapter data') # taking advantage of update_children and other functions never checking that the ids are valid self.assertRaises(InvalidVersionError, draft_store.update_children, location, - ['i4x://MITx/999/problem/doesntexist']) + ['i4x://MITx/999/problem/doesntexist']) self.assertRaises(InvalidVersionError, draft_store.update_metadata, location, - {'due': datetime.datetime.now(UTC)}) + {'due': datetime.datetime.now(UTC)}) self.assertRaises(InvalidVersionError, draft_store.unpublish, location) - def test_bad_contentstore_request(self): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) def test_delete_course(self): + """ + This test will import a course, make a draft item, and delete it. This will also assert that the + draft content is also deleted + """ module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) content_store = contentstore() + draft_store = modulestore('draft') - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store) + location = CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course').location + + # get a vertical (and components in it) to put into 'draft' + vertical = module_store.get_item(Location(['i4x', 'edX', 'toy', + 'vertical', 'vertical_test', None]), depth=1) + + draft_store.convert_to_draft(vertical.location) + for child in vertical.get_children(): + draft_store.convert_to_draft(child.location) + + # delete the course delete_course(module_store, content_store, location, commit=True) - items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + # assert that there's absolutely no non-draft modules in the course + # this should also include all draft items + items = module_store.get_items(Location(['i4x', 'edX', '999', 'course', None])) self.assertEqual(len(items), 0) + # assert that all content in the asset library is also deleted + assets = content_store.get_all_content_for_course(location) + self.assertEqual(len(assets), 0) + def verify_content_existence(self, store, root_dir, location, dirname, category_name, filename_suffix=''): filesystem = OSFS(root_dir / 'test_export') self.assertTrue(filesystem.exists(dirname)) @@ -632,38 +710,47 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): draft_store = modulestore('draft') content_store = contentstore() - import_from_xml(module_store, 'common/test/data/', ['full']) - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') - - # get a vertical (and components in it) to put into 'draft' - vertical = module_store.get_item(Location(['i4x', 'edX', 'full', - 'vertical', 'vertical_66', None]), depth=1) - - draft_store.clone_item(vertical.location, vertical.location) + import_from_xml(module_store, 'common/test/data/', ['toy']) + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') + # get a vertical (and components in it) to copy into an orphan sub dag + vertical = module_store.get_item( + Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]), + depth=1 + ) # We had a bug where orphaned draft nodes caused export to fail. This is here to cover that case. - draft_store.clone_item(vertical.location, Location(['i4x', 'edX', 'full', - 'vertical', 'no_references', 'draft'])) + vertical.location = mongo.draft.as_draft(vertical.location.replace(name='no_references')) + draft_store.save_xmodule(vertical) + orphan_vertical = draft_store.get_item(vertical.location) + self.assertEqual(orphan_vertical.location.name, 'no_references') + # get the original vertical (and components in it) to put into 'draft' + vertical = module_store.get_item( + Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]), + depth=1) + self.assertEqual(len(orphan_vertical.children), len(vertical.children)) + draft_store.convert_to_draft(vertical.location) for child in vertical.get_children(): - draft_store.clone_item(child.location, child.location) + draft_store.convert_to_draft(child.location) root_dir = path(mkdtemp_clean()) - # now create a private vertical - private_vertical = draft_store.clone_item(vertical.location, - Location(['i4x', 'edX', 'full', 'vertical', 'a_private_vertical', None])) + # now create a new/different private (draft only) vertical + vertical.location = mongo.draft.as_draft(Location(['i4x', 'edX', 'toy', 'vertical', 'a_private_vertical', None])) + draft_store.save_xmodule(vertical) + private_vertical = draft_store.get_item(vertical.location) + vertical = None # blank out b/c i destructively manipulated its location 2 lines above - # add private to list of children - sequential = module_store.get_item(Location(['i4x', 'edX', 'full', - 'sequential', 'Administrivia_and_Circuit_Elements', None])) + # add the new private to list of children + sequential = module_store.get_item(Location(['i4x', 'edX', 'toy', + 'sequential', 'vertical_sequential', None])) private_location_no_draft = private_vertical.location.replace(revision=None) module_store.update_children(sequential.location, sequential.children + [private_location_no_draft.url()]) # read back the sequential, to make sure we have a pointer to - sequential = module_store.get_item(Location(['i4x', 'edX', 'full', - 'sequential', 'Administrivia_and_Circuit_Elements', None])) + sequential = module_store.get_item(Location(['i4x', 'edX', 'toy', + 'sequential', 'vertical_sequential', None])) self.assertIn(private_location_no_draft.url(), sequential.children) @@ -675,17 +762,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # check for static tabs self.verify_content_existence(module_store, root_dir, location, 'tabs', 'static_tab', '.html') - # check for custom_tags - self.verify_content_existence(module_store, root_dir, location, 'info', 'course_info', '.html') - - # check for custom_tags - self.verify_content_existence(module_store, root_dir, location, 'custom_tags', 'custom_tag_template') - # check for about content self.verify_content_existence(module_store, root_dir, location, 'about', 'about', '.html') # check for graiding_policy.json - filesystem = OSFS(root_dir / 'test_export/policies/6.002_Spring_2012') + filesystem = OSFS(root_dir / 'test_export/policies/2012_Fall') self.assertTrue(filesystem.exists('grading_policy.json')) course = module_store.get_item(location) @@ -700,8 +781,8 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # compare what's on disk to what we have in the course module with filesystem.open('policy.json', 'r') as course_policy: on_disk = loads(course_policy.read()) - self.assertIn('course/6.002_Spring_2012', on_disk) - self.assertEqual(on_disk['course/6.002_Spring_2012'], own_metadata(course)) + self.assertIn('course/2012_Fall', on_disk) + self.assertEqual(on_disk['course/2012_Fall'], own_metadata(course)) # remove old course delete_course(module_store, content_store, location) @@ -709,7 +790,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # reimport import_from_xml(module_store, root_dir, ['test_export'], draft_store=draft_store) - items = module_store.get_items(Location(['i4x', 'edX', 'full', 'vertical', None])) + items = module_store.get_items(Location(['i4x', 'edX', 'toy', 'vertical', None])) self.assertGreater(len(items), 0) for descriptor in items: # don't try to look at private verticals. Right now we're running @@ -720,54 +801,81 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) # verify that we have the content in the draft store as well - vertical = draft_store.get_item(Location(['i4x', 'edX', 'full', - 'vertical', 'vertical_66', None]), depth=1) + vertical = draft_store.get_item(Location(['i4x', 'edX', 'toy', + 'vertical', 'vertical_test', None]), depth=1) self.assertTrue(getattr(vertical, 'is_draft', False)) for child in vertical.get_children(): self.assertTrue(getattr(child, 'is_draft', False)) # make sure that we don't have a sequential that is in draft mode - sequential = draft_store.get_item(Location(['i4x', 'edX', 'full', - 'sequential', 'Administrivia_and_Circuit_Elements', None])) + sequential = draft_store.get_item(Location(['i4x', 'edX', 'toy', + 'sequential', 'vertical_sequential', None])) self.assertFalse(getattr(sequential, 'is_draft', False)) # verify that we have the private vertical - test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'full', - 'vertical', 'vertical_66', None])) + test_private_vertical = draft_store.get_item(Location(['i4x', 'edX', 'toy', + 'vertical', 'a_private_vertical', None])) self.assertTrue(getattr(test_private_vertical, 'is_draft', False)) # make sure the textbook survived the export/import - course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None])) self.assertGreater(len(course.textbooks), 0) shutil.rmtree(root_dir) + def test_export_course_with_metadata_only_video(self): + module_store = modulestore('direct') + draft_store = modulestore('draft') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy']) + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') + + # create a new video module and add it as a child to a vertical + # this re-creates a bug whereby since the video template doesn't have + # anything in 'data' field, the export was blowing up + verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None]) + + self.assertGreater(len(verticals), 0) + + parent = verticals[0] + + ItemFactory.create(parent_location=parent.location, category="video", display_name="untitled") + + root_dir = path(mkdtemp_clean()) + + print 'Exporting to tempdir = {0}'.format(root_dir) + + # export out to a tempdir + export_to_xml(module_store, content_store, location, root_dir, 'test_export', draft_modulestore=draft_store) + + shutil.rmtree(root_dir) + def test_course_handouts_rewrites(self): module_store = modulestore('direct') # import a test course - import_from_xml(module_store, 'common/test/data/', ['full']) + import_from_xml(module_store, 'common/test/data/', ['toy']) - handout_location = Location(['i4x', 'edX', 'full', 'course_info', 'handouts']) + handout_location = Location(['i4x', 'edX', 'toy', 'course_info', 'handouts']) # get module info resp = self.client.get(reverse('module_info', kwargs={'module_location': handout_location})) # make sure we got a successful response self.assertEqual(resp.status_code, 200) - # check that /static/ has been converted to the full path - # note, we know the link it should be because that's what in the 'full' course in the test data - self.assertContains(resp, '/c4x/edX/full/asset/handouts_schematic_tutorial.pdf') + # note, we know the link it should be because that's what in the 'toy' course in the test data + self.assertContains(resp, '/c4x/edX/toy/asset/handouts_sample_handout.txt') def test_prefetch_children(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + import_from_xml(module_store, 'common/test/data/', ['toy']) + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') wrapper = MongoCollectionFindWrapper(module_store.collection.find) module_store.collection.find = wrapper.find @@ -779,19 +887,19 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): self.assertEqual(wrapper.counter, 4) # make sure we pre-fetched a known sequential which should be at depth=2 - self.assertTrue(Location(['i4x', 'edX', 'full', 'sequential', - 'Administrivia_and_Circuit_Elements', None]) in course.system.module_data) + self.assertTrue(Location(['i4x', 'edX', 'toy', 'sequential', + 'vertical_sequential', None]) in course.system.module_data) # make sure we don't have a specific vertical which should be at depth=3 - self.assertFalse(Location(['i4x', 'edX', 'full', 'vertical', 'vertical_58', None]) + self.assertFalse(Location(['i4x', 'edX', 'toy', 'vertical', 'vertical_test', None]) in course.system.module_data) def test_export_course_with_unknown_metadata(self): module_store = modulestore('direct') content_store = contentstore() - import_from_xml(module_store, 'common/test/data/', ['full']) - location = CourseDescriptor.id_to_location('edX/full/6.002_Spring_2012') + import_from_xml(module_store, 'common/test/data/', ['toy']) + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') root_dir = path(mkdtemp_clean()) @@ -801,6 +909,9 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # add a bool piece of unknown metadata so we can verify we don't throw an exception metadata['new_metadata'] = True + # Save the data that we've just changed to the underlying + # MongoKeyValueStore before we update the mongo datastore. + course.save() module_store.update_metadata(location, metadata) print 'Exporting to tempdir = {0}'.format(root_dir) @@ -809,6 +920,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): export_to_xml(module_store, content_store, location, root_dir, 'test_export') +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ContentStoreTest(ModuleStoreTestCase): """ Tests for the CMS ContentStore application. @@ -839,14 +951,24 @@ class ContentStoreTest(ModuleStoreTestCase): self.client.login(username=uname, password=password) self.course_data = { - 'template': 'i4x://edx/templates/course/Empty', 'org': 'MITx', 'number': '999', 'display_name': 'Robot Super Course', } + def tearDown(self): + mongo = MongoClient() + mongo.drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() + def test_create_course(self): """Test new course creation - happy path""" + self.assert_created_course() + + def assert_created_course(self): + """ + Checks that the course was created properly. + """ resp = self.client.post(reverse('create_new_course'), self.course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) @@ -854,41 +976,72 @@ class ContentStoreTest(ModuleStoreTestCase): def test_create_course_check_forum_seeding(self): """Test new course creation and verify forum seeding """ - resp = self.client.post(reverse('create_new_course'), self.course_data) - self.assertEqual(resp.status_code, 200) - data = parse_json(resp) - self.assertEqual(data['id'], 'i4x://MITx/999/course/Robot_Super_Course') + self.assert_created_course() self.assertTrue(are_permissions_roles_seeded('MITx/999/Robot_Super_Course')) def test_create_course_duplicate_course(self): """Test new course creation - error path""" self.client.post(reverse('create_new_course'), self.course_data) + self.assert_course_creation_failed('There is already a course defined with this name.') + + def assert_course_creation_failed(self, error_message): + """ + Checks that the course did not get created + """ resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], 'There is already a course defined with this name.') + data = parse_json(resp) + self.assertEqual(data['ErrMsg'], error_message) def test_create_course_duplicate_number(self): """Test new course creation - error path""" self.client.post(reverse('create_new_course'), self.course_data) self.course_data['display_name'] = 'Robot Super Course Two' - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) - - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - 'There is already a course defined with the same organization and course number.') + self.assert_course_creation_failed('There is already a course defined with the same organization and course number.') def test_create_course_with_bad_organization(self): """Test new course creation - error path for bad organization name""" self.course_data['org'] = 'University of California, Berkeley' - resp = self.client.post(reverse('create_new_course'), self.course_data) - data = parse_json(resp) + self.assert_course_creation_failed( + "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") - self.assertEqual(resp.status_code, 200) - self.assertEqual(data['ErrMsg'], - "Unable to create course 'Robot Super Course'.\n\nInvalid characters in 'University of California, Berkeley'.") + def test_create_course_with_course_creation_disabled_staff(self): + """Test new course creation -- course creation disabled, but staff access.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + self.assert_created_course() + + def test_create_course_with_course_creation_disabled_not_staff(self): + """Test new course creation -- error path for course creation disabled, not staff access.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'DISABLE_COURSE_CREATION': True}): + self.user.is_staff = False + self.user.save() + self.assert_course_permission_denied() + + def test_create_course_no_course_creators_staff(self): + """Test new course creation -- course creation group enabled, staff, group is empty.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_CREATOR_GROUP': True}): + self.assert_created_course() + + def test_create_course_no_course_creators_not_staff(self): + """Test new course creation -- error path for course creator group enabled, not staff, group is empty.""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + self.user.is_staff = False + self.user.save() + self.assert_course_permission_denied() + + def test_create_course_with_course_creator(self): + """Test new course creation -- use course creator group""" + with mock.patch.dict('django.conf.settings.MITX_FEATURES', {"ENABLE_CREATOR_GROUP": True}): + add_user_to_creator_group(self.user, self.user) + self.assert_created_course() + + def assert_course_permission_denied(self): + """ + Checks that the course did not get created due to a PermissionError. + """ + resp = self.client.post(reverse('create_new_course'), self.course_data) + self.assertEqual(resp.status_code, 403) def test_course_index_view_with_no_courses(self): """Test viewing the index page with no courses""" @@ -941,17 +1094,17 @@ class ContentStoreTest(ModuleStoreTestCase): html=True ) - def test_clone_item(self): + def test_create_item(self): """Test cloning an item. E.g. creating a new section""" CourseFactory.create(org='MITx', course='999', display_name='Robot Super Course') section_data = { 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', - 'template': 'i4x://edx/templates/chapter/Empty', + 'category': 'chapter', 'display_name': 'Section One', } - resp = self.client.post(reverse('clone_item'), section_data) + resp = self.client.post(reverse('create_item'), section_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) @@ -966,14 +1119,14 @@ class ContentStoreTest(ModuleStoreTestCase): problem_data = { 'parent_location': 'i4x://MITx/999/course/Robot_Super_Course', - 'template': 'i4x://edx/templates/problem/Blank_Common_Problem' + 'category': 'problem' } - resp = self.client.post(reverse('clone_item'), problem_data) + resp = self.client.post(reverse('create_item'), problem_data) self.assertEqual(resp.status_code, 200) payload = parse_json(resp) - problem_loc = payload['id'] + problem_loc = Location(payload['id']) problem = get_modulestore(problem_loc).get_item(problem_loc) # should be a CapaDescriptor self.assertIsInstance(problem, CapaDescriptor, "New problem is not a CapaDescriptor") @@ -1103,13 +1256,12 @@ class ContentStoreTest(ModuleStoreTestCase): def test_forum_id_generation(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') - new_component_location = Location('i4x', 'edX', 'full', 'discussion', 'new_component') - source_template_location = Location('i4x', 'edx', 'templates', 'discussion', 'Discussion_Tag') + new_component_location = Location('i4x', 'edX', '999', 'discussion', 'new_component') # crate a new module and add it as a child to a vertical - module_store.clone_item(source_template_location, new_component_location) + module_store.create_and_save_xmodule(new_component_location) new_discussion_item = module_store.get_item(new_component_location) @@ -1117,7 +1269,7 @@ class ContentStoreTest(ModuleStoreTestCase): def test_update_modulestore_signal_did_fire(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') try: module_store.modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location']) @@ -1129,11 +1281,10 @@ class ContentStoreTest(ModuleStoreTestCase): module_store.modulestore_update_signal.connect(_signal_hander) - new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component') - source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') + new_component_location = Location('i4x', 'edX', '999', 'html', 'new_component') # crate a new module - module_store.clone_item(source_template_location, new_component_location) + module_store.create_and_save_xmodule(new_component_location) finally: module_store.modulestore_update_signal = None @@ -1142,23 +1293,23 @@ class ContentStoreTest(ModuleStoreTestCase): def test_metadata_inheritance(self): module_store = modulestore('direct') - import_from_xml(module_store, 'common/test/data/', ['full']) + import_from_xml(module_store, 'common/test/data/', ['toy']) - course = module_store.get_item(Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None])) + course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None])) - verticals = module_store.get_items(['i4x', 'edX', 'full', 'vertical', None, None]) + verticals = module_store.get_items(['i4x', 'edX', 'toy', 'vertical', None, None]) # let's assert on the metadata_inheritance on an existing vertical for vertical in verticals: self.assertEqual(course.lms.xqa_key, vertical.lms.xqa_key) + self.assertEqual(course.start, vertical.lms.start) self.assertGreater(len(verticals), 0) - new_component_location = Location('i4x', 'edX', 'full', 'html', 'new_component') - source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') + new_component_location = Location('i4x', 'edX', 'toy', 'html', 'new_component') # crate a new module and add it as a child to a vertical - module_store.clone_item(source_template_location, new_component_location) + module_store.create_and_save_xmodule(new_component_location) parent = verticals[0] module_store.update_children(parent.location, parent.children + [new_component_location.url()]) @@ -1168,6 +1319,8 @@ class ContentStoreTest(ModuleStoreTestCase): # check for grace period definition which should be defined at the course level self.assertEqual(parent.lms.graceperiod, new_module.lms.graceperiod) + self.assertEqual(parent.lms.start, new_module.lms.start) + self.assertEqual(course.start, new_module.lms.start) self.assertEqual(course.lms.xqa_key, new_module.lms.xqa_key) @@ -1175,6 +1328,7 @@ class ContentStoreTest(ModuleStoreTestCase): # now let's define an override at the leaf node level # new_module.lms.graceperiod = timedelta(1) + new_module.save() module_store.update_metadata(new_module.location, own_metadata(new_module)) # flush the cache and refetch @@ -1183,29 +1337,85 @@ class ContentStoreTest(ModuleStoreTestCase): self.assertEqual(timedelta(1), new_module.lms.graceperiod) + def test_default_metadata_inheritance(self): + course = CourseFactory.create() + vertical = ItemFactory.create(parent_location=course.location) + course.children.append(vertical) + # in memory + self.assertIsNotNone(course.start) + self.assertEqual(course.start, vertical.lms.start) + self.assertEqual(course.textbooks, []) + self.assertIn('GRADER', course.grading_policy) + self.assertIn('GRADE_CUTOFFS', course.grading_policy) + self.assertGreaterEqual(len(course.checklists), 4) -class TemplateTestCase(ModuleStoreTestCase): - - def test_template_cleanup(self): + # by fetching module_store = modulestore('direct') + fetched_course = module_store.get_item(course.location) + fetched_item = module_store.get_item(vertical.location) + self.assertIsNotNone(fetched_course.start) + self.assertEqual(course.start, fetched_course.start) + self.assertEqual(fetched_course.start, fetched_item.lms.start) + self.assertEqual(course.textbooks, fetched_course.textbooks) + # is this test too strict? i.e., it requires the dicts to be == + self.assertEqual(course.checklists, fetched_course.checklists) - # insert a bogus template in the store - bogus_template_location = Location('i4x', 'edx', 'templates', 'html', 'bogus') - source_template_location = Location('i4x', 'edx', 'templates', 'html', 'Blank_HTML_Page') - module_store.clone_item(source_template_location, bogus_template_location) +class MetadataSaveTestCase(ModuleStoreTestCase): + """ + Test that metadata is correctly decached. + """ - verify_create = module_store.get_item(bogus_template_location) - self.assertIsNotNone(verify_create) + def setUp(self): + sample_xml = ''' + + ''' + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + course_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) - # now run cleanup - update_templates(modulestore('direct')) + model_data = {'data': sample_xml} + self.descriptor = ItemFactory.create(parent_location=course_location, category='video', data=model_data) - # now try to find dangling template, it should not be in DB any longer - asserted = False - try: - verify_create = module_store.get_item(bogus_template_location) - except ItemNotFoundError: - asserted = True + def test_metadata_persistence(self): + """ + Test that descriptors which set metadata fields in their + constructor are correctly persisted. + """ + # We should start with a source field, from the XML's tag + self.assertIn('source', own_metadata(self.descriptor)) + attrs_to_strip = { + 'show_captions', + 'youtube_id_1_0', + 'youtube_id_0_75', + 'youtube_id_1_25', + 'youtube_id_1_5', + 'start_time', + 'end_time', + 'source', + 'track' + } + # We strip out all metadata fields to reproduce a bug where + # constructors which set their fields (e.g. Video) didn't have + # those changes persisted. So in the end we have the XML data + # in `descriptor.data`, but not in the individual fields + fields = self.descriptor.fields + for field in fields: + if field.name in attrs_to_strip: + field.delete_from(self.descriptor) - self.assertTrue(asserted) + # Assert that we correctly stripped the field + self.assertNotIn('source', own_metadata(self.descriptor)) + get_modulestore(self.descriptor.location).update_metadata( + self.descriptor.location, + own_metadata(self.descriptor) + ) + module = get_modulestore(self.descriptor.location).get_item(self.descriptor.location) + # Assert that get_item correctly sets the metadata + self.assertIn('source', own_metadata(module)) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 6b8622f992..0862eb462d 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -6,8 +6,6 @@ import json import copy import mock -from django.contrib.auth.models import User -from django.test.client import Client from django.core.urlresolvers import reverse from django.utils.timezone import UTC from django.test.utils import override_settings @@ -16,46 +14,15 @@ from xmodule.modulestore import Location from models.settings.course_details import (CourseDetails, CourseSettingsEncoder) from models.settings.course_grading import CourseGradingModel from contentstore.utils import get_modulestore - -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory + from models.settings.course_metadata import CourseMetadata from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.modulestore.django import modulestore from xmodule.fields import Date - -class CourseTestCase(ModuleStoreTestCase): - """ - Base class for test classes below. - """ - def setUp(self): - """ - These tests need a user in the DB so that the django Test Client - can log them in. - They inherit from the ModuleStoreTestCase class so that the mongodb collection - will be cleared out before each test case execution and deleted - afterwards. - """ - uname = 'testuser' - email = 'test+courses@edx.org' - password = 'foo' - - # Create the use so we can log them in. - self.user = User.objects.create_user(uname, email, password) - - # Note that we do not actually need to do anything - # for registration if we directly mark them active. - self.user.is_active = True - # Staff has access to view all courses - self.user.is_staff = True - self.user.save() - - self.client = Client() - self.client.login(username=uname, password=password) - - course = CourseFactory.create(template='i4x://edx/templates/course/Empty', org='MITx', number='999', display_name='Robot Super Course') - self.course_location = course.location +from .utils import CourseTestCase class CourseDetailsTestCase(CourseTestCase): @@ -63,27 +30,25 @@ class CourseDetailsTestCase(CourseTestCase): Tests the first course settings page (course dates, overview, etc.). """ def test_virgin_fetch(self): - details = CourseDetails.fetch(self.course_location) - self.assertEqual(details.course_location, self.course_location, "Location not copied into") + details = CourseDetails.fetch(self.course.location) + self.assertEqual(details.course_location, self.course.location, "Location not copied into") self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) self.assertIsNone(details.enrollment_end, "enrollment_end date somehow initialized " + str(details.enrollment_end)) self.assertIsNone(details.syllabus, "syllabus somehow initialized" + str(details.syllabus)) - self.assertEqual(details.overview, "", "overview somehow initialized" + details.overview) self.assertIsNone(details.intro_video, "intro_video somehow initialized" + str(details.intro_video)) self.assertIsNone(details.effort, "effort somehow initialized" + str(details.effort)) def test_encoder(self): - details = CourseDetails.fetch(self.course_location) + details = CourseDetails.fetch(self.course.location) jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) - self.assertTupleEqual(Location(jsondetails['course_location']), self.course_location, "Location !=") + self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=") self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") self.assertIsNone(jsondetails['syllabus'], "syllabus somehow initialized") - self.assertEqual(jsondetails['overview'], "", "overview somehow initialized") self.assertIsNone(jsondetails['intro_video'], "intro_video somehow initialized") self.assertIsNone(jsondetails['effort'], "effort somehow initialized") @@ -91,10 +56,12 @@ class CourseDetailsTestCase(CourseTestCase): """ Test the encoder out of its original constrained purpose to see if it functions for general use """ - details = {'location': Location(['tag', 'org', 'course', 'category', 'name']), - 'number': 1, - 'string': 'string', - 'datetime': datetime.datetime.now(UTC())} + details = { + 'location': Location(['tag', 'org', 'course', 'category', 'name']), + 'number': 1, + 'string': 'string', + 'datetime': datetime.datetime.now(UTC()) + } jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) @@ -105,8 +72,7 @@ class CourseDetailsTestCase(CourseTestCase): self.assertEqual(jsondetails['string'], 'string') def test_update_and_fetch(self): - # # NOTE: I couldn't figure out how to validly test time setting w/ all the conversions - jsondetails = CourseDetails.fetch(self.course_location) + jsondetails = CourseDetails.fetch(self.course.location) jsondetails.syllabus = "bar" # encode - decode to convert date fields and other data which changes form self.assertEqual( @@ -128,12 +94,22 @@ class CourseDetailsTestCase(CourseTestCase): CourseDetails.update_from_json(jsondetails.__dict__).effort, jsondetails.effort, "After set effort" ) + jsondetails.start_date = datetime.datetime(2010, 10, 1, 0, tzinfo=UTC()) + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).start_date, + jsondetails.start_date + ) @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) def test_marketing_site_fetch(self): - settings_details_url = reverse('settings_details', - kwargs={'org': self.course_location.org, 'name': self.course_location.name, - 'course': self.course_location.course}) + settings_details_url = reverse( + 'settings_details', + kwargs={ + 'org': self.course.location.org, + 'name': self.course.location.name, + 'course': self.course.location.course + } + ) with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': True}): response = self.client.get(settings_details_url) @@ -150,9 +126,14 @@ class CourseDetailsTestCase(CourseTestCase): self.assertNotContains(response, "Requirements") def test_regular_site_fetch(self): - settings_details_url = reverse('settings_details', - kwargs={'org': self.course_location.org, 'name': self.course_location.name, - 'course': self.course_location.course}) + settings_details_url = reverse( + 'settings_details', + kwargs={ + 'org': self.course.location.org, + 'name': self.course.location.name, + 'course': self.course.location.course + } + ) with mock.patch.dict('django.conf.settings.MITX_FEATURES', {'ENABLE_MKTG_SITE': False}): response = self.client.get(settings_details_url) @@ -190,11 +171,12 @@ class CourseDetailsViewTest(CourseTestCase): return Date().to_json(dt) def test_update_and_fetch(self): - details = CourseDetails.fetch(self.course_location) + loc = self.course.location + details = CourseDetails.fetch(loc) # resp s/b json from here on - url = reverse('course_settings', kwargs={'org': self.course_location.org, 'course': self.course_location.course, - 'name': self.course_location.name, 'section': 'details'}) + url = reverse('course_settings', kwargs={'org': loc.org, 'course': loc.course, + 'name': loc.name, 'section': 'details'}) resp = self.client.get(url) self.compare_details_with_encoding(json.loads(resp.content), details.__dict__, "virgin get") @@ -225,8 +207,7 @@ class CourseDetailsViewTest(CourseTestCase): dt1 = date.from_json(encoded[field]) dt2 = details[field] - expected_delta = datetime.timedelta(0) - self.assertEqual(dt1 - dt2, expected_delta, str(dt1) + "!=" + str(dt2) + " at " + context) + self.assertEqual(dt1, dt2, msg="{} != {} at {}".format(dt1, dt2, context)) else: self.fail(field + " missing from encoded but in details at " + context) elif field in encoded and encoded[field] is not None: @@ -238,49 +219,49 @@ class CourseGradingTest(CourseTestCase): Tests for the course settings grading page. """ def test_initial_grader(self): - descriptor = get_modulestore(self.course_location).get_item(self.course_location) + descriptor = get_modulestore(self.course.location).get_item(self.course.location) test_grader = CourseGradingModel(descriptor) # ??? How much should this test bake in expectations about defaults and thus fail if defaults change? - self.assertEqual(self.course_location, test_grader.course_location, "Course locations") + self.assertEqual(self.course.location, test_grader.course_location, "Course locations") self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") def test_fetch_grader(self): - test_grader = CourseGradingModel.fetch(self.course_location.url()) - self.assertEqual(self.course_location, test_grader.course_location, "Course locations") + test_grader = CourseGradingModel.fetch(self.course.location.url()) + self.assertEqual(self.course.location, test_grader.course_location, "Course locations") self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") - test_grader = CourseGradingModel.fetch(self.course_location) - self.assertEqual(self.course_location, test_grader.course_location, "Course locations") + test_grader = CourseGradingModel.fetch(self.course.location) + self.assertEqual(self.course.location, test_grader.course_location, "Course locations") self.assertIsNotNone(test_grader.graders, "No graders") self.assertIsNotNone(test_grader.grade_cutoffs, "No cutoffs") for i, grader in enumerate(test_grader.graders): - subgrader = CourseGradingModel.fetch_grader(self.course_location, i) + subgrader = CourseGradingModel.fetch_grader(self.course.location, i) self.assertDictEqual(grader, subgrader, str(i) + "th graders not equal") - subgrader = CourseGradingModel.fetch_grader(self.course_location.list(), 0) + subgrader = CourseGradingModel.fetch_grader(self.course.location.list(), 0) self.assertDictEqual(test_grader.graders[0], subgrader, "failed with location as list") def test_fetch_cutoffs(self): - test_grader = CourseGradingModel.fetch_cutoffs(self.course_location) + test_grader = CourseGradingModel.fetch_cutoffs(self.course.location) # ??? should this check that it's at least a dict? (expected is { "pass" : 0.5 } I think) self.assertIsNotNone(test_grader, "No cutoffs via fetch") - test_grader = CourseGradingModel.fetch_cutoffs(self.course_location.url()) + test_grader = CourseGradingModel.fetch_cutoffs(self.course.location.url()) self.assertIsNotNone(test_grader, "No cutoffs via fetch with url") def test_fetch_grace(self): - test_grader = CourseGradingModel.fetch_grace_period(self.course_location) + test_grader = CourseGradingModel.fetch_grace_period(self.course.location) # almost a worthless test self.assertIn('grace_period', test_grader, "No grace via fetch") - test_grader = CourseGradingModel.fetch_grace_period(self.course_location.url()) + test_grader = CourseGradingModel.fetch_grace_period(self.course.location.url()) self.assertIn('grace_period', test_grader, "No cutoffs via fetch with url") def test_update_from_json(self): - test_grader = CourseGradingModel.fetch(self.course_location) + test_grader = CourseGradingModel.fetch(self.course.location) altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "Noop update") @@ -294,11 +275,10 @@ class CourseGradingTest(CourseTestCase): test_grader.grace_period = {'hours': 4, 'minutes': 5, 'seconds': 0} altered_grader = CourseGradingModel.update_from_json(test_grader.__dict__) - print test_grader.grace_period, altered_grader.grace_period self.assertDictEqual(test_grader.__dict__, altered_grader.__dict__, "4 hour grace period") def test_update_grader_from_json(self): - test_grader = CourseGradingModel.fetch(self.course_location) + test_grader = CourseGradingModel.fetch(self.course.location) altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "Noop update") @@ -310,6 +290,71 @@ class CourseGradingTest(CourseTestCase): altered_grader = CourseGradingModel.update_grader_from_json(test_grader.course_location, test_grader.graders[1]) self.assertDictEqual(test_grader.graders[1], altered_grader, "drop_count[1] + 2") + def test_update_cutoffs_from_json(self): + test_grader = CourseGradingModel.fetch(self.course.location) + CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) + # Unlike other tests, need to actually perform a db fetch for this test since update_cutoffs_from_json + # simply returns the cutoffs you send into it, rather than returning the db contents. + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "Noop update") + + test_grader.grade_cutoffs['D'] = 0.3 + CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff add D") + + test_grader.grade_cutoffs['Pass'] = 0.75 + CourseGradingModel.update_cutoffs_from_json(test_grader.course_location, test_grader.grade_cutoffs) + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grade_cutoffs, altered_grader.grade_cutoffs, "cutoff change 'Pass'") + + def test_delete_grace_period(self): + test_grader = CourseGradingModel.fetch(self.course.location) + CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period) + # update_grace_period_from_json doesn't return anything, so query the db for its contents. + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertEqual(test_grader.grace_period, altered_grader.grace_period, "Noop update") + + test_grader.grace_period = {'hours': 15, 'minutes': 5, 'seconds': 30} + CourseGradingModel.update_grace_period_from_json(test_grader.course_location, test_grader.grace_period) + altered_grader = CourseGradingModel.fetch(self.course.location) + self.assertDictEqual(test_grader.grace_period, altered_grader.grace_period, "Adding in a grace period") + + test_grader.grace_period = {'hours': 1, 'minutes': 10, 'seconds': 0} + # Now delete the grace period + CourseGradingModel.delete_grace_period(test_grader.course_location) + # update_grace_period_from_json doesn't return anything, so query the db for its contents. + altered_grader = CourseGradingModel.fetch(self.course.location) + # Once deleted, the grace period should simply be None + self.assertEqual(None, altered_grader.grace_period, "Delete grace period") + + def test_update_section_grader_type(self): + # Get the descriptor and the section_grader_type and assert they are the default values + descriptor = get_modulestore(self.course.location).get_item(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + + self.assertEqual('Not Graded', section_grader_type['graderType']) + self.assertEqual(None, descriptor.lms.format) + self.assertEqual(False, descriptor.lms.graded) + + # Change the default grader type to Homework, which should also mark the section as graded + CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Homework'}) + descriptor = get_modulestore(self.course.location).get_item(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + + self.assertEqual('Homework', section_grader_type['graderType']) + self.assertEqual('Homework', descriptor.lms.format) + self.assertEqual(True, descriptor.lms.graded) + + # Change the grader type back to Not Graded, which should also unmark the section as graded + CourseGradingModel.update_section_grader_type(self.course.location, {'graderType': 'Not Graded'}) + descriptor = get_modulestore(self.course.location).get_item(self.course.location) + section_grader_type = CourseGradingModel.get_section_grader_type(self.course.location) + + self.assertEqual('Not Graded', section_grader_type['graderType']) + self.assertEqual(None, descriptor.lms.format) + self.assertEqual(False, descriptor.lms.graded) + class CourseMetadataEditingTest(CourseTestCase): """ @@ -317,35 +362,34 @@ class CourseMetadataEditingTest(CourseTestCase): """ def setUp(self): CourseTestCase.setUp(self) - # add in the full class too - import_from_xml(get_modulestore(self.course_location), 'common/test/data/', ['full']) - self.fullcourse_location = Location(['i4x', 'edX', 'full', 'course', '6.002_Spring_2012', None]) + CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') + self.fullcourse_location = Location(['i4x', 'edX', '999', 'course', 'Robot_Super_Course', None]) def test_fetch_initial_fields(self): - test_model = CourseMetadata.fetch(self.course_location) + test_model = CourseMetadata.fetch(self.course.location) self.assertIn('display_name', test_model, 'Missing editable metadata field') self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") test_model = CourseMetadata.fetch(self.fullcourse_location) self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') self.assertIn('display_name', test_model, 'full missing editable metadata field') - self.assertEqual(test_model['display_name'], 'Testing', "not expected value") + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') self.assertIn('showanswer', test_model, 'showanswer field ') self.assertIn('xqa_key', test_model, 'xqa_key field ') def test_update_from_json(self): - test_model = CourseMetadata.update_from_json(self.course_location, { + test_model = CourseMetadata.update_from_json(self.course.location, { "advertised_start": "start A", "testcenter_info": {"c": "test"}, "days_early_for_beta": 2 }) self.update_check(test_model) # try fresh fetch to ensure persistence - test_model = CourseMetadata.fetch(self.course_location) + test_model = CourseMetadata.fetch(self.course.location) self.update_check(test_model) # now change some of the existing metadata - test_model = CourseMetadata.update_from_json(self.course_location, { + test_model = CourseMetadata.update_from_json(self.course.location, { "advertised_start": "start B", "display_name": "jolly roger"} ) @@ -369,8 +413,40 @@ class CourseMetadataEditingTest(CourseTestCase): # ensure no harm self.assertNotIn('graceperiod', test_model, 'blacklisted field leaked in') self.assertIn('display_name', test_model, 'full missing editable metadata field') - self.assertEqual(test_model['display_name'], 'Testing', "not expected value") + self.assertEqual(test_model['display_name'], 'Robot Super Course', "not expected value") self.assertIn('rerandomize', test_model, 'Missing rerandomize metadata field') # check for deletion effectiveness - self.assertEqual('closed', test_model['showanswer'], 'showanswer field still in') + self.assertEqual('finished', test_model['showanswer'], 'showanswer field still in') self.assertEqual(None, test_model['xqa_key'], 'xqa_key field still in') + + +class CourseGraderUpdatesTest(CourseTestCase): + def setUp(self): + super(CourseGraderUpdatesTest, self).setUp() + self.url = reverse("course_settings", kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + 'grader_index': 0, + }) + + def test_get(self): + resp = self.client.get(self.url) + self.assert2XX(resp.status_code) + obj = json.loads(resp.content) + + def test_delete(self): + resp = self.client.delete(self.url) + self.assert2XX(resp.status_code) + + def test_post(self): + grader = { + "type": "manual", + "min_count": 5, + "drop_count": 10, + "short_label": "yo momma", + "weight": 17.3, + } + resp = self.client.post(self.url, grader) + self.assert2XX(resp.status_code) + obj = json.loads(resp.content) diff --git a/cms/djangoapps/contentstore/tests/test_course_updates.py b/cms/djangoapps/contentstore/tests/test_course_updates.py index ae14555b32..30114496c8 100644 --- a/cms/djangoapps/contentstore/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/tests/test_course_updates.py @@ -10,9 +10,9 @@ class CourseUpdateTest(CourseTestCase): '''Go through each interface and ensure it works.''' # first get the update to force the creation url = reverse('course_info', - kwargs={'org': self.course_location.org, - 'course': self.course_location.course, - 'name': self.course_location.name}) + kwargs={'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name}) self.client.get(url) init_content = ' + + +
+ +

+ +

+

+

+ +
+ + +
+ +
+ + + +
+ +

+ +

+

+

+ +
+ +
+ + diff --git a/common/static/js/capa/src/jsinput.js b/common/static/js/capa/src/jsinput.js new file mode 100644 index 0000000000..9d3fde32fc --- /dev/null +++ b/common/static/js/capa/src/jsinput.js @@ -0,0 +1,199 @@ +(function (jsinput, undefined) { + // Initialize js inputs on current page. + // N.B.: No library assumptions about the iframe can be made (including, + // most relevantly, jquery). Keep in mind what happens in which context + // when modifying this file. + + /* Check whether there is anything to be done */ + + // When all the problems are first loaded, we want to make sure the + // constructor only runs once for each iframe; but we also want to make + // sure that if part of the page is reloaded (e.g., a problem is + // submitted), the constructor is called again. + + if (!jsinput) { + jsinput = { + runs : 1, + arr : [], + exists : function(id) { + jsinput.arr.filter(function(e, i, a) { + return e.id = id; + }); + } + }; + } + + jsinput.runs++; + + + /* Utils */ + + + // Take a string and find the nested object that corresponds to it. E.g.: + // deepKey(obj, "an.example") -> obj["an"]["example"] + var _deepKey = function(obj, path){ + for (var i = 0, p=path.split('.'), len = p.length; i < len; i++){ + obj = obj[p[i]]; + } + return obj; + }; + + + /* END Utils */ + + + + + function jsinputConstructor(spec) { + // Define an class that will be instantiated for each jsinput element + // of the DOM + + // 'that' is the object returned by the constructor. It has a single + // public method, "update", which updates the hidden input field. + var that = {}; + + /* Private methods */ + + var sect = $(spec.elem).parent().find('section[class="jsinput"]'); + var sectAttr = function (e) { return $(sect).attr(e); }; + var thisIFrame = $(spec.elem). + find('iframe[name^="iframe_"]'). + get(0); + var cWindow = thisIFrame.contentWindow; + + // Get the hidden input field to pass to customresponse + function _inputField() { + var parent = $(spec.elem).parent(); + return parent.find('input[id^="input_"]'); + } + var inputField = _inputField(); + + // Get the grade function name + var getGradeFn = sectAttr("data"); + // Get state getter + var getStateGetter = sectAttr("data-getstate"); + // Get state setter + var getStateSetter = sectAttr("data-setstate"); + // Get stored state + var getStoredState = sectAttr("data-stored"); + + + + // Put the return value of gradeFn in the hidden inputField. + var update = function () { + var ans; + + ans = _deepKey(cWindow, gradeFn)(); + // setstate presumes getstate, so don't getstate unless setstate is + // defined. + if (getStateGetter && getStateSetter) { + var state, store; + state = unescape(_deepKey(cWindow, getStateGetter)()); + store = { + answer: ans, + state: state + }; + inputField.val(JSON.stringify(store)); + } else { + inputField.val(ans); + } + return; + }; + + /* Public methods */ + + that.update = update; + + + + /* Initialization */ + + jsinput.arr.push(that); + + // Put the update function as the value of the inputField's "waitfor" + // attribute so that it is called when the check button is clicked. + function bindCheck() { + inputField.data('waitfor', that.update); + return; + } + + var gradeFn = getGradeFn; + + + bindCheck(); + + // Check whether application takes in state and there is a saved + // state to give it. If getStateSetter is specified but calling it + // fails, wait and try again, since the iframe might still be + // loading. + if (getStateSetter && getStoredState) { + var sval, jsonVal; + + try { + jsonVal = JSON.parse(getStoredState); + } catch (err) { + jsonVal = getStoredState; + } + + if (typeof(jsonVal) === "object") { + sval = jsonVal["state"]; + } else { + sval = jsonVal; + } + + + // Try calling setstate every 200ms while it throws an exception, + // up to five times; give up after that. + // (Functions in the iframe may not be ready when we first try + // calling it, but might just need more time. Give the functions + // more time.) + function whileloop(n) { + if (n < 5){ + try { + _deepKey(cWindow, getStateSetter)(sval); + } catch (err) { + setTimeout(whileloop(n+1), 200); + } + } + else { + console.debug("Error: could not set state"); + } + } + whileloop(0); + + } + + + return that; + } + + + function walkDOM() { + var newid; + + // Find all jsinput elements, and create a jsinput object for each one + var all = $(document).find('section[class="jsinput"]'); + + all.each(function(index, value) { + // Get just the mako variable 'id' from the id attribute + newid = $(value).attr("id").replace(/^inputtype_/, ""); + + + if (!jsinput.exists(newid)){ + var newJsElem = jsinputConstructor({ + id: newid, + elem: value, + }); + } + }); + } + + // This is ugly, but without a timeout pages with multiple/heavy jsinputs + // don't load properly. + if ($.isReady) { + setTimeout(walkDOM, 300); + } else { + $(document).ready(setTimeout(walkDOM, 300)); + } + +})(window.jsinput = window.jsinput || false); diff --git a/common/static/js/test/i18n.js b/common/static/js/test/i18n.js index 3cd6d52ae8..42131211a2 100644 --- a/common/static/js/test/i18n.js +++ b/common/static/js/test/i18n.js @@ -1 +1 @@ -window.gettext = window.ngettext = function(){}; +window.gettext = window.ngettext = function(s){return s;}; diff --git a/common/static/js/utility.js b/common/static/js/utility.js new file mode 100644 index 0000000000..6407faad48 --- /dev/null +++ b/common/static/js/utility.js @@ -0,0 +1,20 @@ +// checks whether or not the url is external to the local site. +// generously provided by StackOverflow: http://stackoverflow.com/questions/6238351/fastest-way-to-detect-external-urls +function isExternal(url) { + // parse the url into protocol, host, path, query, and fragment. More information can be found here: http://tools.ietf.org/html/rfc3986#appendix-B + var match = url.match(/^([^:\/?#]+:)?(?:\/\/([^\/?#]*))?([^?#]+)?(\?[^#]*)?(#.*)?/); + // match[1] matches a protocol if one exists in the url + // if the protocol in the url does not match the protocol in the window's location, this url is considered external + if (typeof match[1] === "string" && + match[1].length > 0 + && match[1].toLowerCase() !== location.protocol) + return true; + // match[2] matches the host if one exists in the url + // if the host in the url does not match the host of the window location, this url is considered external + if (typeof match[2] === "string" && + match[2].length > 0 && + // this regex removes the port number if it patches the current location's protocol + match[2].replace(new RegExp(":("+{"http:":80,"https:":443}[location.protocol]+")?$"), "") !== location.host) + return true; + return false; +} diff --git a/common/static/js/vendor/backbone-associations-min.js b/common/static/js/vendor/backbone-associations-min.js new file mode 100644 index 0000000000..77d398d962 --- /dev/null +++ b/common/static/js/vendor/backbone-associations-min.js @@ -0,0 +1,11 @@ +(function(){var v=this,g,h,w,m,r,s,z,o,A,B;"undefined"===typeof window?(g=require("underscore"),h=require("backbone"),"undefined"!==typeof exports&&(exports=module.exports=h)):(g=v._,h=v.Backbone);w=h.Model;m=h.Collection;r=w.prototype;s=m.prototype;A=/[\.\[\]]+/g;z="change add remove reset sort destroy".split(" ");B=["reset","sort"];h.Associations={VERSION:"0.5.0"};h.Associations.Many=h.Many="Many";h.Associations.One=h.One="One";o=h.AssociatedModel=h.Associations.AssociatedModel=w.extend({relations:void 0, +_proxyCalls:void 0,get:function(a){var c=r.get.call(this,a);return c?c:this._getAttr.apply(this,arguments)},set:function(a,c,d){var b;if(g.isObject(a)||a==null){b=a;d=c}else{b={};b[a]=c}a=this._set(b,d);this._processPendingEvents();return a},_set:function(a,c){var d,b,n,f,j=this;if(!a)return this;for(d in a){b||(b={});if(d.match(A)){var k=x(d);f=g.initial(k);k=k[k.length-1];f=this.get(f);if(f instanceof o){f=b[f.cid]||(b[f.cid]={model:f,data:{}});f.data[k]=a[d]}}else{f=b[this.cid]||(b[this.cid]={model:this, +data:{}});f.data[d]=a[d]}}if(b)for(n in b){f=b[n];this._setAttr.call(f.model,f.data,c)||(j=false)}else j=this._setAttr.call(this,a,c);return j},_setAttr:function(a,c){var d;c||(c={});if(c.unset)for(d in a)a[d]=void 0;this.parents=this.parents||[];this.relations&&g.each(this.relations,function(b){var d=b.key,f=b.relatedModel,j=b.collectionType,k=b.map,i=this.attributes[d],y=i&&i.idAttribute,e,q,l,p;f&&g.isString(f)&&(f=t(f));j&&g.isString(j)&&(j=t(j));k&&g.isString(k)&&(k=t(k));q=b.options?g.extend({}, +b.options,c):c;if(a[d]){e=g.result(a,d);e=k?k(e):e;if(b.type===h.Many){if(j&&!j.prototype instanceof m)throw Error("collectionType must inherit from Backbone.Collection");if(e instanceof m)l=e;else if(i){i._deferEvents=true;i.set(e,c);l=i}else{l=j?new j:this._createCollection(f);l.add(e,q)}}else if(b.type===h.One&&f)if(e instanceof o)l=e;else if(i)if(i&&e[y]&&i.get(y)===e[y]){i._deferEvents=true;i._set(e,c);l=i}else l=new f(e,q);else l=new f(e,q);if((p=a[d]=l)&&!p._proxyCallback){p._proxyCallback= +function(){return this._bubbleEvent.call(this,d,p,arguments)};p.on("all",p._proxyCallback,this)}}if(a.hasOwnProperty(d)){b=a[d];f=this.attributes[d];if(b){b.parents=b.parents||[];g.indexOf(b.parents,this)==-1&&b.parents.push(this)}else if(f&&f.parents.length>0)f.parents=g.difference(f.parents,[this])}},this);return r.set.call(this,a,c)},_bubbleEvent:function(a,c,d){var b=d[0].split(":"),n=b[0],f=d[0]=="nested-change",j=d[1],k=d[2],i=-1,h=c._proxyCalls,e,q=g.indexOf(z,n)!==-1;if(!f){g.size(b)>1&&(e= +b[1]);g.indexOf(B,n)!==-1&&(k=j);if(c instanceof m&&q&&j){var l=x(e),p=g.initial(l);(b=c.find(function(a){if(j===a)return true;if(!a)return false;var b=a.get(p);if((b instanceof o||b instanceof m)&&j===b)return true;b=a.get(l);if((b instanceof o||b instanceof m)&&j===b||b instanceof m&&k&&k===b)return true}))&&(i=c.indexOf(b))}e=a+(i!==-1&&(n==="change"||e)?"["+i+"]":"")+(e?"."+e:"");if(/\[\*\]/g.test(e))return this;b=e.replace(/\[\d+\]/g,"[*]");i=[];i.push.apply(i,d);i[0]=n+":"+e;h=c._proxyCalls= +h||{};if(this._isEventAvailable.call(this,h,e))return this;h[e]=true;if("change"===n){this._previousAttributes[a]=c._previousAttributes;this.changed[a]=c}this.trigger.apply(this,i);"change"===n&&this.get(e)!=d[2]&&this.trigger.apply(this,["nested-change",e,d[1]]);h&&e&&delete h[e];if(e!==b){i[0]=n+":"+b;this.trigger.apply(this,i)}return this}},_isEventAvailable:function(a,c){return g.find(a,function(a,b){return c.indexOf(b,c.length-b.length)!==-1})},_createCollection:function(a){var c=a;g.isString(c)&& +(c=t(c));if(c&&c.prototype instanceof o){a=new m;a.model=c}else throw Error("type must inherit from Backbone.AssociatedModel");return a},_processPendingEvents:function(){if(!this.visited){this.visited=true;this._deferEvents=false;g.each(this._pendingEvents,function(a){a.c.trigger.apply(a.c,a.a)});this._pendingEvents=[];g.each(this.relations,function(a){(a=this.attributes[a.key])&&a._processPendingEvents()},this);delete this.visited}},trigger:function(a){if(this._deferEvents){this._pendingEvents=this._pendingEvents|| +[];this._pendingEvents.push({c:this,a:arguments})}else r.trigger.apply(this,arguments)},toJSON:function(a){var c,d;if(!this.visited){this.visited=true;c=r.toJSON.apply(this,arguments);this.relations&&g.each(this.relations,function(b){var h=this.attributes[b.key];if(h){d=h.toJSON(a);c[b.key]=g.isArray(d)?g.compact(d):d}},this);delete this.visited}return c},clone:function(){return new this.constructor(this.toJSON())},_getAttr:function(a){var c=this,a=x(a),d,b;if(!(g.size(a)<1)){for(b=0;b= 0) { + if (callThat.call(context || this, this.calls[i]) === true) { + return this.calls[i]; + } + i--; + } + }; + + jasmine.Matchers.ArgThat = (function(_super) { + + __extends(ArgThat, _super); + + function ArgThat(matcher) { + this.matcher = matcher; + } + + ArgThat.prototype.jasmineMatches = function(actual) { + return this.matcher(actual); + }; + + return ArgThat; + + })(jasmine.Matchers.Any); + + jasmine.Matchers.ArgThat.prototype.matches = jasmine.Matchers.ArgThat.prototype.jasmineMatches; + + jasmine.argThat = function(expected) { + return new jasmine.Matchers.ArgThat(expected); + }; + + jasmine.Matchers.Capture = (function(_super) { + + __extends(Capture, _super); + + function Capture(captor) { + this.captor = captor; + } + + Capture.prototype.jasmineMatches = function(actual) { + this.captor.value = actual; + return true; + }; + + return Capture; + + })(jasmine.Matchers.Any); + + jasmine.Matchers.Capture.prototype.matches = jasmine.Matchers.Capture.prototype.jasmineMatches; + + Captor = (function() { + + function Captor() {} + + Captor.prototype.capture = function() { + return new jasmine.Matchers.Capture(this); + }; + + return Captor; + + })(); + + jasmine.captor = function() { + return new Captor(); + }; + +}).call(this); diff --git a/cms/static/sass/_mixins-inherited.scss b/common/static/sass/_mixins-inherited.scss similarity index 90% rename from cms/static/sass/_mixins-inherited.scss rename to common/static/sass/_mixins-inherited.scss index c0d9df77ce..5a834fa256 100644 --- a/cms/static/sass/_mixins-inherited.scss +++ b/common/static/sass/_mixins-inherited.scss @@ -108,8 +108,8 @@ // inherited - ui .window { @include clearfix(); - @include border-radius(3px); - @include box-shadow(0 1px 1px $shadow-l1); + box-shadow: 0 1px 1px $shadow-l1; + border-radius: 3px; margin-bottom: $baseline; border: 1px solid $gray-l2; background: $white; @@ -120,13 +120,13 @@ // mixins - grandfathered @mixin button { @include font-size(14); - @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 0 0 rgba(0, 0, 0, 0)); - @include transition(background-color .15s, box-shadow .15s); + @include transition(background-color 0.15s, box-shadow 0.15s); + box-shadow: 0 1px 0 rgba(255, 255, 255, .3) inset, 0 0 0 rgba(0, 0, 0, 0); display: inline-block; padding: ($baseline/5) $baseline ($baseline/4); font-weight: 700; - &.disabled { + &.disabled, &.is-disabled { border: 1px solid $gray-l1 !important; border-radius: 3px !important; background: $gray-l1 !important; @@ -139,14 +139,14 @@ } &:hover, &.active { - @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset, 0 1px 1px rgba(0, 0, 0, .15)); + box-shadow: 0 1px 0 rgba(255, 255, 255, .3) inset, 0 1px 1px rgba(0, 0, 0, .15); } } @mixin green-button { @include button; @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0)); - @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset); + box-shadow: 0 1px 0 rgba(255, 255, 255, .3) inset; border: 1px solid $green-d1; border-radius: 3px; background-color: $green; @@ -157,11 +157,11 @@ color: $white; } - &.disabled { + &.disabled, &.is-disabled { border: 1px solid $green-l3 !important; background: $green-l3 !important; color: $white !important; - @include box-shadow(none); + box-shadow: none; } } @@ -178,8 +178,8 @@ color: $white; } - &.disabled { - @include box-shadow(none); + &.disabled, &.is-disabled { + box-shadow: none; border: 1px solid $blue-l3 !important; background: $blue-l3 !important; color: $white !important; @@ -199,8 +199,8 @@ color: $white; } - &.disabled { - @include box-shadow(none); + &.disabled, &.is-disabled { + box-shadow: none; border: 1px solid $red-l3 !important; background: $red-l3 !important; color: $white !important; @@ -220,8 +220,8 @@ color: $white; } - &.disabled { - @include box-shadow(none); + &.disabled, &.is-disabled { + box-shadow: none; border: 1px solid $pink-l3 !important; background: $pink-l3 !important; color: $white !important; @@ -231,7 +231,7 @@ @mixin orange-button { @include button; @include linear-gradient(top, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0) 60%); - @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset); + box-shadow: 0 1px 0 rgba(255, 255, 255, .3) inset; border: 1px solid $orange-d1; border-radius: 3px; background-color: $orange; @@ -242,18 +242,18 @@ color: $gray-d2; } - &.disabled { + &.disabled, &.is-disabled { border: 1px solid $orange-l3 !important; background: $orange-l2 !important; color: $gray-l1 !important; - @include box-shadow(none); + box-shadow: none; } } @mixin white-button { @include button; @include linear-gradient(top, rgba(255, 255, 255, 0.6), rgba(255, 255, 255, 0)); - @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset); + box-shadow: 0 1px 0 rgba(255, 255, 255, .3) inset; border: 1px solid $mediumGrey; border-radius: 3px; background-color: #dfe5eb; @@ -269,7 +269,7 @@ @mixin grey-button { @include button; @include linear-gradient(top, rgba(255, 255, 255, .3), rgba(255, 255, 255, 0)); - @include box-shadow(0 1px 0 rgba(255, 255, 255, .3) inset); + box-shadow: 0 1px 0 rgba(255, 255, 255, .3) inset; border: 1px solid $gray-d2; border-radius: 3px; background-color: #d1dae3; @@ -284,7 +284,7 @@ @mixin gray-button { @include button; @include linear-gradient(top, $white-t1, rgba(255, 255, 255, 0)); - @include box-shadow(0 1px 0 $white-t1 inset); + box-shadow: 0 1px 0 $white-t1 inset; border: 1px solid $gray-d1; border-radius: 3px; background-color: $gray-d2; @@ -311,7 +311,7 @@ } @mixin edit-box { - @include box-shadow(0 1px 0 rgba(255, 255, 255, .2) inset); + box-shadow: 0 1px 0 rgba(255, 255, 255, .2) inset; padding: 15px 20px; border-radius: 3px; background-color: $lightBluishGrey2; @@ -448,7 +448,7 @@ // sunsetted mixins @mixin active { @include linear-gradient(top, rgba(255, 255, 255, .4), rgba(255, 255, 255, 0)); - @include box-shadow(0 -1px 0 rgba(0, 0, 0, .2) inset, 0 1px 0 #fff inset); + box-shadow: 0 -1px 0 rgba(0, 0, 0, .2) inset, 0 1px 0 #fff inset; background-color: rgba(255, 255, 255, .3); text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); } diff --git a/common/static/sass/_mixins.scss b/common/static/sass/_mixins.scss index c26738a1b7..d89f1d12d5 100644 --- a/common/static/sass/_mixins.scss +++ b/common/static/sass/_mixins.scss @@ -26,7 +26,6 @@ @include size($size); } - // ==================== // mixins - placeholder styling @@ -44,80 +43,47 @@ // ==================== -// extends - layout - -// used for page/view-level wrappers (for centering/grids) -.wrapper { +// extends - UI - used for page/view-level wrappers (for centering/grids) +.ui-wrapper { @include clearfix(); @include box-sizing(border-box); width: 100%; } -// removes list styling/spacing when using uls, ols for navigation and less content-centric cases -.no-list { - list-style: none; - margin: 0; - padding: 0; - text-indent: 0; - - li { - margin: 0; - padding: 0; - } +// extends - UI - window +.ui-window { + @include clearfix(); + border-radius: 3px; + box-shadow: 0 1px 1px $shadow-l1; + margin-bottom: $baseline; + border: 1px solid $gray-l2; + background: $white; } -// extends - image-replacement hidden text -.text-hide { - text-indent: 100%; - white-space: nowrap; - overflow: hidden; -} - -// extends - hidden elems - screenreaders -.text-sr { - border: 0; - clip: rect(0 0 0 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - width: 1px; -} - -// extends - wrapping -.text-wrap { - text-wrap: wrap; - white-space: pre-wrap; - white-space: -moz-pre-wrap; - word-wrap: break-word; -} - -// extends - visual link -.fake-link { +// extends - UI - visual link +.ui-fake-link { cursor: pointer; } -// extends - functional disable -.disabled { +// extends - UI - functional disable +.ui-disabled { pointer-events: none; outline: none; } -// extends - depth levels -.depth0 { z-index: 0; } -.depth1 { z-index: 10; } -.depth2 { z-index: 100; } -.depth3 { z-index: 1000; } -.depth4 { z-index: 10000; } -.depth5 { z-index: 100000; } +// extends - UI - depth levels +.ui-depth0 { z-index: 0; } +.ui-depth1 { z-index: 10; } +.ui-depth2 { z-index: 100; } +.ui-depth3 { z-index: 1000; } +.ui-depth4 { z-index: 10000; } +.ui-depth5 { z-index: 100000; } -// ==================== -// extends - buttons -.btn { +// extends - UI - buttons +.ui-btn { @include box-sizing(border-box); - @include transition(color 0.25s ease-in-out, border-color 0.25s ease-in-out, background 0.25s ease-in-out, box-shadow 0.25s ease-in-out); + @include transition(color 0.25s ease-in-out 0s, border-color 0.25s ease-in-out 0s, background 0.25s ease-in-out 0s, box-shadow 0.25s ease-in-out 0s); display: inline-block; cursor: pointer; @@ -125,7 +91,7 @@ } - &.disabled, &[disabled] { + &.disabled, &[disabled], &.is-disabled { cursor: default; pointer-events: none; opacity: 0.5; @@ -139,18 +105,18 @@ } // pill button -.btn-pill { - @include border-radius($baseline/5); +.ui-btn-pill { + border-radius: ($baseline/5); } -.btn-rounded { - @include border-radius($baseline/2); +.ui-btn-rounded { + border-radius: ($baseline/2); } // primary button -.btn-primary { - @extend .btn; - @extend .btn-pill; +.ui-btn-primary { + @extend .ui-btn; + @extend .ui-btn-pill; padding:($baseline/2) $baseline; border-width: 1px; border-style: solid; @@ -158,22 +124,22 @@ text-align: center; &:hover, &:active { - @include box-shadow(0 2px 1px $shadow-l1); + box-shadow: 0 2px 1px $shadow-l1; } &.current, &.active { - @include box-shadow(inset 1px 1px 2px $shadow-d1); + box-shadow: inset 1px 1px 2px $shadow-d1; &:hover, &:active { - @include box-shadow(inset 1px 1px 1px $shadow-d1); + box-shadow: inset 1px 1px 1px $shadow-d1; } } } // secondary button -.btn-secondary { - @extend .btn; - @extend .btn-pill; +.ui-btn-secondary { + @extend .ui-btn; + @extend .ui-btn-pill; border-width: 1px; border-style: solid; padding:($baseline/2) $baseline; @@ -190,9 +156,104 @@ } } -// UI archetypes - well +.ui-btn-flat-outline { + @extend .t-action4; + @include transition(all .15s); + font-weight: 600; + text-align: center; + border-radius: ($baseline/4); + border: 1px solid $blue-l2; + padding: 1px ($baseline/2) 2px ($baseline/2); + background-color: $white; + color: $blue-l2; + + &:hover { + border: 1px solid $blue; + background-color: $blue; + color: $white; + } + + &.is-disabled, + &[disabled="disabled"]{ + border: 1px solid $gray-l2; + background-color: $gray-l4; + color: $gray-l2; + pointer-events: none; + } +} + +// button with no button shell until hover for understated actions +.ui-btn-non { + @include transition(all .15s); + border: none; + border-radius: ($baseline/4); + background: none; + padding: 3px ($baseline/2); + vertical-align: middle; + color: $gray-l1; + + &:hover { + background-color: $gray-l1; + color: $white; + } + + span { + @extend .cont-text-sr; + } +} + +// extends - UI archetypes - well .ui-well { - @include box-shadow(inset 0 1px 2px 1px $shadow-l1); + box-shadow: inset 0 1px 2px 1px $shadow; padding: ($baseline*0.75); } +// ==================== + +// extends - content - removes list styling/spacing when using uls, ols for navigation and less content-centric cases +.cont-no-list { + list-style: none; + margin: 0; + padding: 0; + text-indent: 0; + + li { + margin: 0; + padding: 0; + } +} + +// extends - content - image-replacement hidden text +.cont-text-hide { + text-indent: 100%; + white-space: nowrap; + overflow: hidden; +} + +// extends - content - hidden elems - screenreaders +.cont-text-sr { + border: 0; + clip: rect(0 0 0 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; +} + +// extends - content - wrapping +.cont-text-wrap { + text-wrap: wrap; + white-space: pre-wrap; + white-space: -moz-pre-wrap; + word-wrap: break-word; +} + +// extends - content - text overflow by ellipsis +.cont-truncated { + @include box-sizing(border-box); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/common/static/sass/bourbon/_bourbon-deprecated-upcoming.scss b/common/static/sass/bourbon/_bourbon-deprecated-upcoming.scss new file mode 100644 index 0000000000..5332496d82 --- /dev/null +++ b/common/static/sass/bourbon/_bourbon-deprecated-upcoming.scss @@ -0,0 +1,13 @@ +//************************************************************************// +// These mixins/functions are deprecated +// They will be removed in the next MAJOR version release +//************************************************************************// +@mixin box-shadow ($shadows...) { + @include prefixer(box-shadow, $shadows, spec); + @warn "box-shadow is deprecated and will be removed in the next major version release"; +} + +@mixin background-size ($lengths...) { + @include prefixer(background-size, $lengths, spec); + @warn "background-size is deprecated and will be removed in the next major version release"; +} diff --git a/common/static/sass/bourbon/_bourbon.scss b/common/static/sass/bourbon/_bourbon.scss index 27b056e303..53fbca877f 100644 --- a/common/static/sass/bourbon/_bourbon.scss +++ b/common/static/sass/bourbon/_bourbon.scss @@ -1,35 +1,59 @@ +// Custom Helpers +@import "helpers/deprecated-webkit-gradient"; +@import "helpers/gradient-positions-parser"; +@import "helpers/linear-positions-parser"; +@import "helpers/radial-arg-parser"; +@import "helpers/radial-positions-parser"; +@import "helpers/render-gradients"; +@import "helpers/shape-size-stripper"; + // Custom Functions -@import "functions/deprecated-webkit-gradient"; +@import "functions/compact"; @import "functions/flex-grid"; @import "functions/grid-width"; @import "functions/linear-gradient"; @import "functions/modular-scale"; +@import "functions/px-to-em"; @import "functions/radial-gradient"; -@import "functions/render-gradients"; @import "functions/tint-shade"; +@import "functions/transition-property-name"; // CSS3 Mixins @import "css3/animation"; @import "css3/appearance"; +@import "css3/backface-visibility"; +@import "css3/background"; @import "css3/background-image"; -@import "css3/background-size"; @import "css3/border-image"; @import "css3/border-radius"; -@import "css3/box-shadow"; @import "css3/box-sizing"; @import "css3/columns"; @import "css3/flex-box"; +@import "css3/font-face"; +@import "css3/hidpi-media-query"; +@import "css3/image-rendering"; @import "css3/inline-block"; +@import "css3/keyframes"; @import "css3/linear-gradient"; +@import "css3/perspective"; @import "css3/radial-gradient"; @import "css3/transform"; @import "css3/transition"; @import "css3/user-select"; +@import "css3/placeholder"; // Addons & other mixins @import "addons/button"; @import "addons/clearfix"; @import "addons/font-family"; +@import "addons/hide-text"; @import "addons/html5-input-types"; @import "addons/position"; +@import "addons/prefixer"; +@import "addons/retina-image"; +@import "addons/size"; @import "addons/timing-functions"; +@import "addons/triangle"; + +// Soon to be deprecated Mixins +@import "bourbon-deprecated-upcoming"; diff --git a/common/static/sass/bourbon/addons/_button.scss b/common/static/sass/bourbon/addons/_button.scss index 1d32125140..3ae393c090 100644 --- a/common/static/sass/bourbon/addons/_button.scss +++ b/common/static/sass/bourbon/addons/_button.scss @@ -34,6 +34,11 @@ @include pill($base-color); } } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } } @@ -59,18 +64,19 @@ } border: 1px solid $border; - @include border-radius (3px); - @include box-shadow (inset 0 1px 0 0 $inset-shadow); + border-radius: 3px; + box-shadow: inset 0 1px 0 0 $inset-shadow; color: $color; - display: inline; + display: inline-block; font-size: 11px; font-weight: bold; @include linear-gradient ($base-color, $stop-gradient); - padding: 6px 18px 7px; + padding: 7px 18px; + text-decoration: none; text-shadow: 0 1px 0 $text-shadow; - -webkit-background-clip: padding-box; + background-clip: padding-box; - &:hover { + &:hover:not(:disabled) { $base-color-hover: adjust-color($base-color, $saturation: -4%, $lightness: -5%); $inset-shadow-hover: adjust-color($base-color, $saturation: -7%, $lightness: 5%); $stop-gradient-hover: adjust-color($base-color, $saturation: 8%, $lightness: -14%); @@ -81,12 +87,12 @@ $stop-gradient-hover: grayscale($stop-gradient-hover); } - @include box-shadow (inset 0 1px 0 0 $inset-shadow-hover); + box-shadow: inset 0 1px 0 0 $inset-shadow-hover; cursor: pointer; @include linear-gradient ($base-color-hover, $stop-gradient-hover); } - &:active { + &:active:not(:disabled) { $border-active: adjust-color($base-color, $saturation: 9%, $lightness: -14%); $inset-shadow-active: adjust-color($base-color, $saturation: 7%, $lightness: -17%); @@ -96,7 +102,7 @@ } border: 1px solid $border-active; - @include box-shadow (inset 0 0 8px 4px $inset-shadow-active, inset 0 0 8px 4px $inset-shadow-active, 0 1px 1px 0 #eee); + box-shadow: inset 0 0 8px 4px $inset-shadow-active, inset 0 0 8px 4px $inset-shadow-active, 0 1px 1px 0 #eee; } } @@ -130,19 +136,19 @@ border: 1px solid $border; border-bottom: 1px solid $border-bottom; - @include border-radius(5px); - @include box-shadow(inset 0 1px 0 0 $inset-shadow); + border-radius: 5px; + box-shadow: inset 0 1px 0 0 $inset-shadow; color: $color; - display: inline; + display: inline-block; font-size: 14px; font-weight: bold; @include linear-gradient(top, $base-color 0%, $second-stop 50%, $third-stop 50%, $fourth-stop 100%); - padding: 7px 20px 8px; + padding: 8px 20px; text-align: center; text-decoration: none; text-shadow: 0 -1px 1px $text-shadow; - &:hover { + &:hover:not(:disabled) { $first-stop-hover: adjust-color($base-color, $red: -13, $green: -15, $blue: -18); $second-stop-hover: adjust-color($base-color, $red: -66, $green: -62, $blue: -51); $third-stop-hover: adjust-color($base-color, $red: -93, $green: -85, $blue: -66); @@ -162,14 +168,14 @@ $fourth-stop-hover 100%); } - &:active { + &:active:not(:disabled) { $inset-shadow-active: adjust-color($base-color, $red: -111, $green: -116, $blue: -122); @if $grayscale == true { $inset-shadow-active: grayscale($inset-shadow-active); } - @include box-shadow(inset 0 0 20px 0 $inset-shadow-active, 0 1px 0 #fff); + box-shadow: inset 0 0 20px 0 $inset-shadow-active, 0 1px 0 #fff; } } @@ -201,20 +207,21 @@ border: 1px solid $border-top; border-color: $border-top $border-sides $border-bottom; - @include border-radius(16px); - @include box-shadow(inset 0 1px 0 0 $inset-shadow, 0 1px 2px 0 #b3b3b3); + border-radius: 16px; + box-shadow: inset 0 1px 0 0 $inset-shadow, 0 1px 2px 0 #b3b3b3; color: $color; - display: inline; + display: inline-block; font-size: 11px; font-weight: normal; line-height: 1; @include linear-gradient ($base-color, $stop-gradient); - padding: 3px 16px 5px; + padding: 5px 16px; text-align: center; + text-decoration: none; text-shadow: 0 -1px 1px $text-shadow; - -webkit-background-clip: padding-box; + background-clip: padding-box; - &:hover { + &:hover:not(:disabled) { $base-color-hover: adjust-color($base-color, $lightness: -4.5%); $border-bottom: adjust-color($base-color, $hue: 8, $saturation: 13.5%, $lightness: -32%); $border-sides: adjust-color($base-color, $hue: 4, $saturation: -2%, $lightness: -27%); @@ -235,14 +242,14 @@ border: 1px solid $border-top; border-color: $border-top $border-sides $border-bottom; - @include box-shadow(inset 0 1px 0 0 $inset-shadow-hover); + box-shadow: inset 0 1px 0 0 $inset-shadow-hover; cursor: pointer; @include linear-gradient ($base-color-hover, $stop-gradient-hover); text-shadow: 0 -1px 1px $text-shadow-hover; - -webkit-background-clip: padding-box; + background-clip: padding-box; } - &:active { + &:active:not(:disabled) { $active-color: adjust-color($base-color, $hue: 4, $saturation: -12%, $lightness: -10%); $border-active: adjust-color($base-color, $hue: 6, $saturation: -2.5%, $lightness: -30%); $border-bottom-active: adjust-color($base-color, $hue: 11, $saturation: 6%, $lightness: -31%); @@ -260,8 +267,7 @@ background: $active-color; border: 1px solid $border-active; border-bottom: 1px solid $border-bottom-active; - @include box-shadow(inset 0 0 6px 3px $inset-shadow-active, 0 1px 0 0 #fff); + box-shadow: inset 0 0 6px 3px $inset-shadow-active, 0 1px 0 0 #fff; text-shadow: 0 -1px 1px $text-shadow-active; } } - diff --git a/common/static/sass/bourbon/addons/_clearfix.scss b/common/static/sass/bourbon/addons/_clearfix.scss index a9f6a795c5..ca9903cf02 100644 --- a/common/static/sass/bourbon/addons/_clearfix.scss +++ b/common/static/sass/bourbon/addons/_clearfix.scss @@ -12,11 +12,11 @@ // } @mixin clearfix { - zoom: 1; + *zoom: 1; &:before, &:after { - content: ""; + content: " "; display: table; } diff --git a/common/static/sass/bourbon/addons/_hide-text.scss b/common/static/sass/bourbon/addons/_hide-text.scss new file mode 100644 index 0000000000..68d4bf86cb --- /dev/null +++ b/common/static/sass/bourbon/addons/_hide-text.scss @@ -0,0 +1,5 @@ +@mixin hide-text { + color: transparent; + font: 0/0 a; + text-shadow: none; +} diff --git a/common/static/sass/bourbon/addons/_html5-input-types.scss b/common/static/sass/bourbon/addons/_html5-input-types.scss index 9d86fbb4d4..b184382d91 100644 --- a/common/static/sass/bourbon/addons/_html5-input-types.scss +++ b/common/static/sass/bourbon/addons/_html5-input-types.scss @@ -21,15 +21,35 @@ $inputs-list: 'input[type="email"]', 'input[type="week"]'; $unquoted-inputs-list: (); - @each $input-type in $inputs-list { $unquoted-inputs-list: append($unquoted-inputs-list, unquote($input-type), comma); } $all-text-inputs: $unquoted-inputs-list; + +// Hover Pseudo-class +//************************************************************************// +$all-text-inputs-hover: (); +@each $input-type in $unquoted-inputs-list { + $input-type-hover: $input-type + ":hover"; + $all-text-inputs-hover: append($all-text-inputs-hover, $input-type-hover, comma); +} + +// Focus Pseudo-class +//************************************************************************// +$all-text-inputs-focus: (); +@each $input-type in $unquoted-inputs-list { + $input-type-focus: $input-type + ":focus"; + $all-text-inputs-focus: append($all-text-inputs-focus, $input-type-focus, comma); +} + // You must use interpolation on the variable: // #{$all-text-inputs} +// #{$all-text-inputs-hover} +// #{$all-text-inputs-focus} + +// Example //************************************************************************// // #{$all-text-inputs}, textarea { // border: 1px solid red; diff --git a/common/static/sass/bourbon/addons/_position.scss b/common/static/sass/bourbon/addons/_position.scss index 6ad330f1df..faad1cae50 100644 --- a/common/static/sass/bourbon/addons/_position.scss +++ b/common/static/sass/bourbon/addons/_position.scss @@ -12,19 +12,31 @@ position: $position; - @if not(unitless($top)) { + @if $top == auto { + top: $top; + } + @else if not(unitless($top)) { top: $top; } - @if not(unitless($right)) { + @if $right == auto { + right: $right; + } + @else if not(unitless($right)) { right: $right; } - @if not(unitless($bottom)) { + @if $bottom == auto { + bottom: $bottom; + } + @else if not(unitless($bottom)) { bottom: $bottom; } - @if not(unitless($left)) { + @if $left == auto { + left: $left; + } + @else if not(unitless($left)) { left: $left; } } diff --git a/common/static/sass/bourbon/addons/_prefixer.scss b/common/static/sass/bourbon/addons/_prefixer.scss new file mode 100644 index 0000000000..6bfd23a1dd --- /dev/null +++ b/common/static/sass/bourbon/addons/_prefixer.scss @@ -0,0 +1,49 @@ +//************************************************************************// +// Example: @include prefixer(border-radius, $radii, webkit ms spec); +//************************************************************************// +$prefix-for-webkit: true !default; +$prefix-for-mozilla: true !default; +$prefix-for-microsoft: true !default; +$prefix-for-opera: true !default; +$prefix-for-spec: true !default; // required for keyframe mixin + +@mixin prefixer ($property, $value, $prefixes) { + @each $prefix in $prefixes { + @if $prefix == webkit { + @if $prefix-for-webkit { + -webkit-#{$property}: $value; + } + } + @else if $prefix == moz { + @if $prefix-for-mozilla { + -moz-#{$property}: $value; + } + } + @else if $prefix == ms { + @if $prefix-for-microsoft { + -ms-#{$property}: $value; + } + } + @else if $prefix == o { + @if $prefix-for-opera { + -o-#{$property}: $value; + } + } + @else if $prefix == spec { + @if $prefix-for-spec { + #{$property}: $value; + } + } + @else { + @warn "Unrecognized prefix: #{$prefix}"; + } + } +} + +@mixin disable-prefix-for-all() { + $prefix-for-webkit: false; + $prefix-for-mozilla: false; + $prefix-for-microsoft: false; + $prefix-for-opera: false; + $prefix-for-spec: false; +} diff --git a/common/static/sass/bourbon/addons/_retina-image.scss b/common/static/sass/bourbon/addons/_retina-image.scss new file mode 100644 index 0000000000..a84b6faebc --- /dev/null +++ b/common/static/sass/bourbon/addons/_retina-image.scss @@ -0,0 +1,32 @@ +@mixin retina-image($filename, $background-size, $extension: png, $retina-filename: null, $asset-pipeline: false) { + @if $asset-pipeline { + background-image: image-url("#{$filename}.#{$extension}"); + } + @else { + background-image: url("#{$filename}.#{$extension}"); + } + + @include hidpi { + + @if $asset-pipeline { + @if $retina-filename { + background-image: image-url("#{$retina-filename}.#{$extension}"); + } + @else { + background-image: image-url("#{$filename}@2x.#{$extension}"); + } + } + + @else { + @if $retina-filename { + background-image: url("#{$retina-filename}.#{$extension}"); + } + @else { + background-image: url("#{$filename}@2x.#{$extension}"); + } + } + + background-size: $background-size; + + } +} diff --git a/common/static/sass/bourbon/addons/_size.scss b/common/static/sass/bourbon/addons/_size.scss new file mode 100644 index 0000000000..342e41b79f --- /dev/null +++ b/common/static/sass/bourbon/addons/_size.scss @@ -0,0 +1,44 @@ +@mixin size($size) { + @if length($size) == 1 { + @if $size == auto { + width: $size; + height: $size; + } + + @else if unitless($size) { + width: $size + px; + height: $size + px; + } + + @else if not(unitless($size)) { + width: $size; + height: $size; + } + } + + // Width x Height + @if length($size) == 2 { + $width: nth($size, 1); + $height: nth($size, 2); + + @if $width == auto { + width: $width; + } + @else if not(unitless($width)) { + width: $width; + } + @else if unitless($width) { + width: $width + px; + } + + @if $height == auto { + height: $height; + } + @else if not(unitless($height)) { + height: $height; + } + @else if unitless($height) { + height: $height + px; + } + } +} diff --git a/common/static/sass/bourbon/addons/_triangle.scss b/common/static/sass/bourbon/addons/_triangle.scss new file mode 100644 index 0000000000..0e02aca2ca --- /dev/null +++ b/common/static/sass/bourbon/addons/_triangle.scss @@ -0,0 +1,45 @@ +@mixin triangle ($size, $color, $direction) { + height: 0; + width: 0; + + @if ($direction == up) or ($direction == down) or ($direction == right) or ($direction == left) { + border-color: transparent; + border-style: solid; + border-width: $size / 2; + + @if $direction == up { + border-bottom-color: $color; + + } @else if $direction == right { + border-left-color: $color; + + } @else if $direction == down { + border-top-color: $color; + + } @else if $direction == left { + border-right-color: $color; + } + } + + @else if ($direction == up-right) or ($direction == up-left) { + border-top: $size solid $color; + + @if $direction == up-right { + border-left: $size solid transparent; + + } @else if $direction == up-left { + border-right: $size solid transparent; + } + } + + @else if ($direction == down-right) or ($direction == down-left) { + border-bottom: $size solid $color; + + @if $direction == down-right { + border-left: $size solid transparent; + + } @else if $direction == down-left { + border-right: $size solid transparent; + } + } +} diff --git a/common/static/sass/bourbon/css3/_animation.scss b/common/static/sass/bourbon/css3/_animation.scss index f99e06eb6f..08c3dbf157 100644 --- a/common/static/sass/bourbon/css3/_animation.scss +++ b/common/static/sass/bourbon/css3/_animation.scss @@ -2,170 +2,51 @@ // Each of these mixins support comma separated lists of values, which allows different transitions for individual properties to be described in a single style rule. Each value in the list corresponds to the value at that same position in the other properties. // Official animation shorthand property. -@mixin animation ($animation-1, - $animation-2: false, $animation-3: false, - $animation-4: false, $animation-5: false, - $animation-6: false, $animation-7: false, - $animation-8: false, $animation-9: false) - { - $full: compact($animation-1, $animation-2, $animation-3, $animation-4, - $animation-5, $animation-6, $animation-7, $animation-8, $animation-9); - - -webkit-animation: $full; - -moz-animation: $full; - animation: $full; +@mixin animation ($animations...) { + @include prefixer(animation, $animations, webkit moz spec); } // Individual Animation Properties -@mixin animation-name ($name-1, - $name-2: false, $name-3: false, - $name-4: false, $name-5: false, - $name-6: false, $name-7: false, - $name-8: false, $name-9: false) - { - $full: compact($name-1, $name-2, $name-3, $name-4, - $name-5, $name-6, $name-7, $name-8, $name-9); - - -webkit-animation-name: $full; - -moz-animation-name: $full; - animation-name: $full; +@mixin animation-name ($names...) { + @include prefixer(animation-name, $names, webkit moz spec); } -@mixin animation-duration ($time-1: 0, - $time-2: false, $time-3: false, - $time-4: false, $time-5: false, - $time-6: false, $time-7: false, - $time-8: false, $time-9: false) - { - $full: compact($time-1, $time-2, $time-3, $time-4, - $time-5, $time-6, $time-7, $time-8, $time-9); - - -webkit-animation-duration: $full; - -moz-animation-duration: $full; - animation-duration: $full; +@mixin animation-duration ($times...) { + @include prefixer(animation-duration, $times, webkit moz spec); } -@mixin animation-timing-function ($motion-1: ease, -// ease | linear | ease-in | ease-out | ease-in-out - $motion-2: false, $motion-3: false, - $motion-4: false, $motion-5: false, - $motion-6: false, $motion-7: false, - $motion-8: false, $motion-9: false) - { - $full: compact($motion-1, $motion-2, $motion-3, $motion-4, - $motion-5, $motion-6, $motion-7, $motion-8, $motion-9); - - -webkit-animation-timing-function: $full; - -moz-animation-timing-function: $full; - animation-timing-function: $full; +@mixin animation-timing-function ($motions...) { +// ease | linear | ease-in | ease-out | ease-in-out + @include prefixer(animation-timing-function, $motions, webkit moz spec); } -@mixin animation-iteration-count ($value-1: 1, -// infinite | - $value-2: false, $value-3: false, - $value-4: false, $value-5: false, - $value-6: false, $value-7: false, - $value-8: false, $value-9: false) - { - $full: compact($value-1, $value-2, $value-3, $value-4, - $value-5, $value-6, $value-7, $value-8, $value-9); - - -webkit-animation-iteration-count: $full; - -moz-animation-iteration-count: $full; - animation-iteration-count: $full; +@mixin animation-iteration-count ($values...) { +// infinite | + @include prefixer(animation-iteration-count, $values, webkit moz spec); } -@mixin animation-direction ($direction-1: normal, -// normal | alternate - $direction-2: false, $direction-3: false, - $direction-4: false, $direction-5: false, - $direction-6: false, $direction-7: false, - $direction-8: false, $direction-9: false) - { - $full: compact($direction-1, $direction-2, $direction-3, $direction-4, - $direction-5, $direction-6, $direction-7, $direction-8, $direction-9); - - -webkit-animation-direction: $full; - -moz-animation-direction: $full; - animation-direction: $full; +@mixin animation-direction ($directions...) { +// normal | alternate + @include prefixer(animation-direction, $directions, webkit moz spec); } -@mixin animation-play-state ($state-1: running, -// running | paused - $state-2: false, $state-3: false, - $state-4: false, $state-5: false, - $state-6: false, $state-7: false, - $state-8: false, $state-9: false) - { - $full: compact($state-1, $state-2, $state-3, $state-4, - $state-5, $state-6, $state-7, $state-8, $state-9); - - -webkit-animation-play-state: $full; - -moz-animation-play-state: $full; - animation-play-state: $full; +@mixin animation-play-state ($states...) { +// running | paused + @include prefixer(animation-play-state, $states, webkit moz spec); } -@mixin animation-delay ($time-1: 0, - $time-2: false, $time-3: false, - $time-4: false, $time-5: false, - $time-6: false, $time-7: false, - $time-8: false, $time-9: false) - { - $full: compact($time-1, $time-2, $time-3, $time-4, - $time-5, $time-6, $time-7, $time-8, $time-9); - - -webkit-animation-delay: $full; - -moz-animation-delay: $full; - animation-delay: $full; +@mixin animation-delay ($times...) { + @include prefixer(animation-delay, $times, webkit moz spec); } -@mixin animation-fill-mode ($mode-1: none, -// http://goo.gl/l6ckm -// none | forwards | backwards | both - $mode-2: false, $mode-3: false, - $mode-4: false, $mode-5: false, - $mode-6: false, $mode-7: false, - $mode-8: false, $mode-9: false) - { - $full: compact($mode-1, $mode-2, $mode-3, $mode-4, - $mode-5, $mode-6, $mode-7, $mode-8, $mode-9); - - -webkit-animation-fill-mode: $full; - -moz-animation-fill-mode: $full; - animation-fill-mode: $full; +@mixin animation-fill-mode ($modes...) { +// none | forwards | backwards | both + @include prefixer(animation-fill-mode, $modes, webkit moz spec); } - - -// Deprecated -@mixin animation-basic ($name, $time: 0, $motion: ease) { - $length-of-name: length($name); - $length-of-time: length($time); - $length-of-motion: length($motion); - - @if $length-of-name > 1 { - @include animation-name(zip($name)); - } @else { - @include animation-name( $name); - } - - @if $length-of-time > 1 { - @include animation-duration(zip($time)); - } @else { - @include animation-duration( $time); - } - - @if $length-of-motion > 1 { - @include animation-timing-function(zip($motion)); - } @else { - @include animation-timing-function( $motion); - } - @warn "The animation-basic mixin is deprecated. Use the animation mixin instead."; -} - diff --git a/common/static/sass/bourbon/css3/_appearance.scss b/common/static/sass/bourbon/css3/_appearance.scss index 548767e166..3eb16e45de 100644 --- a/common/static/sass/bourbon/css3/_appearance.scss +++ b/common/static/sass/bourbon/css3/_appearance.scss @@ -1,7 +1,3 @@ @mixin appearance ($value) { - -webkit-appearance: $value; - -moz-appearance: $value; - -ms-appearance: $value; - -o-appearance: $value; - appearance: $value; + @include prefixer(appearance, $value, webkit moz ms o spec); } diff --git a/common/static/sass/bourbon/css3/_backface-visibility.scss b/common/static/sass/bourbon/css3/_backface-visibility.scss new file mode 100644 index 0000000000..1161fe60dd --- /dev/null +++ b/common/static/sass/bourbon/css3/_backface-visibility.scss @@ -0,0 +1,6 @@ +//************************************************************************// +// Backface-visibility mixin +//************************************************************************// +@mixin backface-visibility($visibility) { + @include prefixer(backface-visibility, $visibility, webkit spec); +} diff --git a/common/static/sass/bourbon/css3/_background-image.scss b/common/static/sass/bourbon/css3/_background-image.scss index c23cef7c31..17016b91b9 100644 --- a/common/static/sass/bourbon/css3/_background-image.scss +++ b/common/static/sass/bourbon/css3/_background-image.scss @@ -3,42 +3,35 @@ // gradients, or for stringing multiple gradients together. //************************************************************************// -@mixin background-image( - $image-1 , $image-2: false, - $image-3: false, $image-4: false, - $image-5: false, $image-6: false, - $image-7: false, $image-8: false, - $image-9: false, $image-10: false -) { - $images: compact($image-1, $image-2, - $image-3, $image-4, - $image-5, $image-6, - $image-7, $image-8, - $image-9, $image-10); - - background-image: add-prefix($images, webkit); - background-image: add-prefix($images, moz); - background-image: add-prefix($images, ms); - background-image: add-prefix($images, o); - background-image: add-prefix($images); +@mixin background-image($images...) { + background-image: _add-prefix($images, webkit); + background-image: _add-prefix($images); } - -@function add-prefix($images, $vendor: false) { +@function _add-prefix($images, $vendor: false) { $images-prefixed: (); - + $gradient-positions: false; @for $i from 1 through length($images) { $type: type-of(nth($images, $i)); // Get type of variable - List or String // If variable is a list - Gradient @if $type == list { - $gradient-type: nth(nth($images, $i), 1); // Get type of gradient (linear || radial) - $gradient-args: nth(nth($images, $i), 2); // Get actual gradient (red, blue) + $gradient-type: nth(nth($images, $i), 1); // linear or radial + $gradient-pos: null; + $gradient-args: null; - $gradient: render-gradients($gradient-args, $gradient-type, $vendor); + @if ($gradient-type == linear) or ($gradient-type == radial) { + $gradient-pos: nth(nth($images, $i), 2); // Get gradient position + $gradient-args: nth(nth($images, $i), 3); // Get actual gradient (red, blue) + } + @else { + $gradient-args: nth(nth($images, $i), 2); // Get actual gradient (red, blue) + } + + $gradient-positions: _gradient-positions-parser($gradient-type, $gradient-pos); + $gradient: _render-gradients($gradient-positions, $gradient-args, $gradient-type, $vendor); $images-prefixed: append($images-prefixed, $gradient, comma); } - // If variable is a string - Image @else if $type == string { $images-prefixed: join($images-prefixed, nth($images, $i), comma); @@ -47,11 +40,9 @@ @return $images-prefixed; } - - //Examples: //@include background-image(linear-gradient(top, orange, red)); //@include background-image(radial-gradient(50% 50%, cover circle, orange, red)); //@include background-image(url("/images/a.png"), linear-gradient(orange, red)); //@include background-image(url("image.png"), linear-gradient(orange, red), url("image.png")); - //@include background-image(linear-gradient(hsla(0, 100%, 100%, 0.25) 0%, hsla(0, 100%, 100%, 0.08) 50%, transparent 50%), linear-gradient(orange, red); + //@include background-image(linear-gradient(hsla(0, 100%, 100%, 0.25) 0%, hsla(0, 100%, 100%, 0.08) 50%, transparent 50%), linear-gradient(orange, red)); diff --git a/common/static/sass/bourbon/css3/_background-size.scss b/common/static/sass/bourbon/css3/_background-size.scss deleted file mode 100644 index 4bba11027d..0000000000 --- a/common/static/sass/bourbon/css3/_background-size.scss +++ /dev/null @@ -1,15 +0,0 @@ -@mixin background-size ($length-1, - $length-2: false, $length-3: false, - $length-4: false, $length-5: false, - $length-6: false, $length-7: false, - $length-8: false, $length-9: false) - { - $full: compact($length-1, $length-2, $length-3, $length-4, - $length-5, $length-6, $length-7, $length-8, $length-9); - - -webkit-background-size: $full; - -moz-background-size: $full; - -ms-background-size: $full; - -o-background-size: $full; - background-size: $full; -} diff --git a/common/static/sass/bourbon/css3/_background.scss b/common/static/sass/bourbon/css3/_background.scss new file mode 100644 index 0000000000..766d5d3224 --- /dev/null +++ b/common/static/sass/bourbon/css3/_background.scss @@ -0,0 +1,103 @@ +//************************************************************************// +// Background property for adding multiple backgrounds using shorthand +// notation. +//************************************************************************// + +@mixin background( + $background-1 , $background-2: false, + $background-3: false, $background-4: false, + $background-5: false, $background-6: false, + $background-7: false, $background-8: false, + $background-9: false, $background-10: false, + $fallback: false +) { + $backgrounds: compact($background-1, $background-2, + $background-3, $background-4, + $background-5, $background-6, + $background-7, $background-8, + $background-9, $background-10); + + $fallback-color: false; + @if (type-of($fallback) == color) or ($fallback == "transparent") { + $fallback-color: $fallback; + } + @else { + $fallback-color: _extract-background-color($backgrounds); + } + + @if $fallback-color { + background-color: $fallback-color; + } + background: _background-add-prefix($backgrounds, webkit); + background: _background-add-prefix($backgrounds); +} + +@function _extract-background-color($backgrounds) { + $final-bg-layer: nth($backgrounds, length($backgrounds)); + @if type-of($final-bg-layer) == list { + @for $i from 1 through length($final-bg-layer) { + $value: nth($final-bg-layer, $i); + @if type-of($value) == color { + @return $value; + } + } + } + @return false; +} + +@function _background-add-prefix($backgrounds, $vendor: false) { + $backgrounds-prefixed: (); + + @for $i from 1 through length($backgrounds) { + $shorthand: nth($backgrounds, $i); // Get member for current index + $type: type-of($shorthand); // Get type of variable - List (gradient) or String (image) + + // If shorthand is a list (gradient) + @if $type == list { + $first-member: nth($shorthand, 1); // Get first member of shorthand + + // Linear Gradient + @if index(linear radial, nth($first-member, 1)) { + $gradient-type: nth($first-member, 1); // linear || radial + $gradient-args: false; + $gradient-positions: false; + $shorthand-start: false; + @if type-of($first-member) == list { // Linear gradient plus additional shorthand values - lg(red,orange)repeat,... + $gradient-positions: nth($first-member, 2); + $gradient-args: nth($first-member, 3); + $shorthand-start: 2; + } + @else { // Linear gradient only - lg(red,orange),... + $gradient-positions: nth($shorthand, 2); + $gradient-args: nth($shorthand, 3); // Get gradient (red, blue) + } + + $gradient-positions: _gradient-positions-parser($gradient-type, $gradient-positions); + $gradient: _render-gradients($gradient-positions, $gradient-args, $gradient-type, $vendor); + + // Append any additional shorthand args to gradient + @if $shorthand-start { + @for $j from $shorthand-start through length($shorthand) { + $gradient: join($gradient, nth($shorthand, $j), space); + } + } + $backgrounds-prefixed: append($backgrounds-prefixed, $gradient, comma); + } + // Image with additional properties + @else { + $backgrounds-prefixed: append($backgrounds-prefixed, $shorthand, comma); + } + } + // If shorthand is a simple string (color or image) + @else if $type == string { + $backgrounds-prefixed: join($backgrounds-prefixed, $shorthand, comma); + } + } + @return $backgrounds-prefixed; +} + +//Examples: + //@include background(linear-gradient(top, orange, red)); + //@include background(radial-gradient(circle at 40% 40%, orange, red)); + //@include background(url("/images/a.png") no-repeat, linear-gradient(orange, red)); + //@include background(url("image.png") center center, linear-gradient(orange, red), url("image.png")); diff --git a/common/static/sass/bourbon/css3/_border-image.scss b/common/static/sass/bourbon/css3/_border-image.scss index da4f20ba49..1fff212df8 100644 --- a/common/static/sass/bourbon/css3/_border-image.scss +++ b/common/static/sass/bourbon/css3/_border-image.scss @@ -1,45 +1,43 @@ @mixin border-image($images) { - -webkit-border-image: border-add-prefix($images, webkit); - -moz-border-image: border-add-prefix($images, moz); - -o-border-image: border-add-prefix($images, o); - border-image: border-add-prefix($images); + -webkit-border-image: _border-add-prefix($images, webkit); + -moz-border-image: _border-add-prefix($images, moz); + -o-border-image: _border-add-prefix($images, o); + border-image: _border-add-prefix($images); } -@function border-add-prefix($images, $vendor: false) { - $border-image: (); +@function _border-add-prefix($images, $vendor: false) { + $border-image: null; $images-type: type-of(nth($images, 1)); $first-var: nth(nth($images, 1), 1); // Get type of Gradient (Linear || radial) // If input is a gradient @if $images-type == string { @if ($first-var == "linear") or ($first-var == "radial") { - @for $i from 2 through length($images) { - $gradient-type: nth($images, 1); // Get type of gradient (linear || radial) - $gradient-args: nth($images, $i); // Get actual gradient (red, blue) - $border-image: render-gradients($gradient-args, $gradient-type, $vendor); - } + $gradient-type: nth($images, 1); // Get type of gradient (linear || radial) + $gradient-pos: nth($images, 2); // Get gradient position + $gradient-args: nth($images, 3); // Get actual gradient (red, blue) + $gradient-positions: _gradient-positions-parser($gradient-type, $gradient-pos); + $border-image: _render-gradients($gradient-positions, $gradient-args, $gradient-type, $vendor); } - // If input is a URL @else { $border-image: $images; } } - // If input is gradient or url + additional args @else if $images-type == list { - @for $i from 1 through length($images) { - $type: type-of(nth($images, $i)); // Get type of variable - List or String + $type: type-of(nth($images, 1)); // Get type of variable - List or String - // If variable is a list - Gradient - @if $type == list { - $gradient-type: nth(nth($images, $i), 1); // Get type of gradient (linear || radial) - $gradient-args: nth(nth($images, $i), 2); // Get actual gradient (red, blue) - $border-image: render-gradients($gradient-args, $gradient-type, $vendor); - } + // If variable is a list - Gradient + @if $type == list { + $gradient: nth($images, 1); + $gradient-type: nth($gradient, 1); // Get type of gradient (linear || radial) + $gradient-pos: nth($gradient, 2); // Get gradient position + $gradient-args: nth($gradient, 3); // Get actual gradient (red, blue) + $gradient-positions: _gradient-positions-parser($gradient-type, $gradient-pos); + $border-image: _render-gradients($gradient-positions, $gradient-args, $gradient-type, $vendor); - // If variable is a string - Image or number - @else if ($type == string) or ($type == number) { + @for $i from 2 through length($images) { $border-image: append($border-image, nth($images, $i)); } } @@ -54,3 +52,4 @@ // @include border-image(linear-gradient(45deg, orange, yellow) stretch); // @include border-image(linear-gradient(45deg, orange, yellow) 20 30 40 50 stretch round); // @include border-image(radial-gradient(top, cover, orange, yellow, orange)); + diff --git a/common/static/sass/bourbon/css3/_border-radius.scss b/common/static/sass/bourbon/css3/_border-radius.scss index f24389ebbe..7c17190109 100644 --- a/common/static/sass/bourbon/css3/_border-radius.scss +++ b/common/static/sass/bourbon/css3/_border-radius.scss @@ -1,63 +1,22 @@ -@mixin border-radius ($radii) { - -webkit-border-radius: $radii; - -moz-border-radius: $radii; - -ms-border-radius: $radii; - -o-border-radius: $radii; - border-radius: $radii; -} - -@mixin border-top-left-radius($radii) { - -webkit-border-top-left-radius: $radii; - -moz-border-top-left-radius: $radii; - -moz-border-radius-topleft: $radii; - -ms-border-top-left-radius: $radii; - -o-border-top-left-radius: $radii; - border-top-left-radius: $radii; -} - -@mixin border-top-right-radius($radii) { - -webkit-border-top-right-radius: $radii; - -moz-border-top-right-radius: $radii; - -moz-border-radius-topright: $radii; - -ms-border-top-right-radius: $radii; - -o-border-top-right-radius: $radii; - border-top-right-radius: $radii; -} - -@mixin border-bottom-left-radius($radii) { - -webkit-border-bottom-left-radius: $radii; - -moz-border-bottom-left-radius: $radii; - -moz-border-radius-bottomleft: $radii; - -ms-border-bottom-left-radius: $radii; - -o-border-bottom-left-radius: $radii; - border-bottom-left-radius: $radii; -} - -@mixin border-bottom-right-radius($radii) { - -webkit-border-bottom-right-radius: $radii; - -moz-border-bottom-right-radius: $radii; - -moz-border-radius-bottomright: $radii; - -ms-border-bottom-right-radius: $radii; - -o-border-bottom-right-radius: $radii; - border-bottom-right-radius: $radii; -} - +//************************************************************************// +// Shorthand Border-radius mixins +//************************************************************************// @mixin border-top-radius($radii) { - @include border-top-left-radius($radii); - @include border-top-right-radius($radii); -} - -@mixin border-right-radius($radii) { - @include border-top-right-radius($radii); - @include border-bottom-right-radius($radii); + @include prefixer(border-top-left-radius, $radii, spec); + @include prefixer(border-top-right-radius, $radii, spec); } @mixin border-bottom-radius($radii) { - @include border-bottom-left-radius($radii); - @include border-bottom-right-radius($radii); + @include prefixer(border-bottom-left-radius, $radii, spec); + @include prefixer(border-bottom-right-radius, $radii, spec); } @mixin border-left-radius($radii) { - @include border-top-left-radius($radii); - @include border-bottom-left-radius($radii); + @include prefixer(border-top-left-radius, $radii, spec); + @include prefixer(border-bottom-left-radius, $radii, spec); +} + +@mixin border-right-radius($radii) { + @include prefixer(border-top-right-radius, $radii, spec); + @include prefixer(border-bottom-right-radius, $radii, spec); } diff --git a/common/static/sass/bourbon/css3/_box-shadow.scss b/common/static/sass/bourbon/css3/_box-shadow.scss deleted file mode 100644 index 327b66d251..0000000000 --- a/common/static/sass/bourbon/css3/_box-shadow.scss +++ /dev/null @@ -1,14 +0,0 @@ -// Box-Shadow Mixin Requires Sass v3.1.1+ -@mixin box-shadow ($shadow-1, - $shadow-2: false, $shadow-3: false, - $shadow-4: false, $shadow-5: false, - $shadow-6: false, $shadow-7: false, - $shadow-8: false, $shadow-9: false) - { - $full: compact($shadow-1, $shadow-2, $shadow-3, $shadow-4, - $shadow-5, $shadow-6, $shadow-7, $shadow-8, $shadow-9); - - -webkit-box-shadow: $full; - -moz-box-shadow: $full; - box-shadow: $full; -} diff --git a/common/static/sass/bourbon/css3/_box-sizing.scss b/common/static/sass/bourbon/css3/_box-sizing.scss index d61523b5f1..f07e1d412e 100644 --- a/common/static/sass/bourbon/css3/_box-sizing.scss +++ b/common/static/sass/bourbon/css3/_box-sizing.scss @@ -1,6 +1,4 @@ @mixin box-sizing ($box) { // content-box | border-box | inherit - -webkit-box-sizing: $box; - -moz-box-sizing: $box; - box-sizing: $box; *behavior: url(/static/scripts/boxsizing.htc); + @include prefixer(box-sizing, $box, webkit moz spec); } diff --git a/common/static/sass/bourbon/css3/_columns.scss b/common/static/sass/bourbon/css3/_columns.scss index 2896c91d7f..42274a4eeb 100644 --- a/common/static/sass/bourbon/css3/_columns.scss +++ b/common/static/sass/bourbon/css3/_columns.scss @@ -1,67 +1,47 @@ @mixin columns($arg: auto) { // || - -webkit-columns: $arg; - -moz-columns: $arg; - columns: $arg; + @include prefixer(columns, $arg, webkit moz spec); } @mixin column-count($int: auto) { // auto || integer - -webkit-column-count: $int; - -moz-column-count: $int; - column-count: $int; + @include prefixer(column-count, $int, webkit moz spec); } @mixin column-gap($length: normal) { // normal || length - -webkit-column-gap: $length; - -moz-column-gap: $length; - column-gap: $length; + @include prefixer(column-gap, $length, webkit moz spec); } @mixin column-fill($arg: auto) { // auto || length - -webkit-columns-fill: $arg; - -moz-columns-fill: $arg; - columns-fill: $arg; + @include prefixer(columns-fill, $arg, webkit moz spec); } @mixin column-rule($arg) { // || || - -webkit-column-rule: $arg; - -moz-column-rule: $arg; - column-rule: $arg; + @include prefixer(column-rule, $arg, webkit moz spec); } @mixin column-rule-color($color) { - -webkit-column-rule-color: $color; - -moz-column-rule-color: $color; - column-rule-color: $color; + @include prefixer(column-rule-color, $color, webkit moz spec); } @mixin column-rule-style($style: none) { // none | hidden | dashed | dotted | double | groove | inset | inset | outset | ridge | solid - -webkit-column-rule-style: $style; - -moz-column-rule-style: $style; - column-rule-style: $style; + @include prefixer(column-rule-style, $style, webkit moz spec); } @mixin column-rule-width ($width: none) { - -webkit-column-rule-width: $width; - -moz-column-rule-width: $width; - column-rule-width: $width; + @include prefixer(column-rule-width, $width, webkit moz spec); } @mixin column-span($arg: none) { // none || all - -webkit-column-span: $arg; - -moz-column-span: $arg; - column-span: $arg; + @include prefixer(column-span, $arg, webkit moz spec); } @mixin column-width($length: auto) { // auto || length - -webkit-column-width: $length; - -moz-column-width: $length; - column-width: $length; + @include prefixer(column-width, $length, webkit moz spec); } diff --git a/common/static/sass/bourbon/css3/_flex-box.scss b/common/static/sass/bourbon/css3/_flex-box.scss index 44c1dfd789..3e741e6696 100644 --- a/common/static/sass/bourbon/css3/_flex-box.scss +++ b/common/static/sass/bourbon/css3/_flex-box.scss @@ -16,52 +16,37 @@ @mixin box-orient($orient: inline-axis) { // horizontal|vertical|inline-axis|block-axis|inherit - -webkit-box-orient: $orient; - -moz-box-orient: $orient; - box-orient: $orient; + @include prefixer(box-orient, $orient, webkit moz spec); } @mixin box-pack($pack: start) { // start|end|center|justify - -webkit-box-pack: $pack; - -moz-box-pack: $pack; - box-pack: $pack; + @include prefixer(box-pack, $pack, webkit moz spec); } @mixin box-align($align: stretch) { // start|end|center|baseline|stretch - -webkit-box-align: $align; - -moz-box-align: $align; - box-align: $align; + @include prefixer(box-align, $align, webkit moz spec); } @mixin box-direction($direction: normal) { // normal|reverse|inherit - -webkit-box-direction: $direction; - -moz-box-direction: $direction; - box-direction: $direction; -} -@mixin box-lines($lines: single) { -// single|multiple - -webkit-box-lines: $lines; - -moz-box-lines: $lines; - box-lines: $lines; + @include prefixer(box-direction, $direction, webkit moz spec); } -@mixin box-ordinal-group($integer: 1) { - -webkit-box-ordinal-group: $integer; - -moz-box-ordinal-group: $integer; - box-ordinal-group: $integer; +@mixin box-lines($lines: single) { +// single|multiple + @include prefixer(box-lines, $lines, webkit moz spec); +} + +@mixin box-ordinal-group($int: 1) { + @include prefixer(box-ordinal-group, $int, webkit moz spec); } @mixin box-flex($value: 0.0) { - -webkit-box-flex: $value; - -moz-box-flex: $value; - box-flex: $value; + @include prefixer(box-flex, $value, webkit moz spec); } -@mixin box-flex-group($integer: 1) { - -webkit-box-flex-group: $integer; - -moz-box-flex-group: $integer; - box-flex-group: $integer; +@mixin box-flex-group($int: 1) { + @include prefixer(box-flex-group, $int, webkit moz spec); } diff --git a/common/static/sass/bourbon/css3/_font-face.scss b/common/static/sass/bourbon/css3/_font-face.scss new file mode 100644 index 0000000000..029ee8fe88 --- /dev/null +++ b/common/static/sass/bourbon/css3/_font-face.scss @@ -0,0 +1,23 @@ +// Order of the includes matters, and it is: normal, bold, italic, bold+italic. + +@mixin font-face($font-family, $file-path, $weight: normal, $style: normal, $asset-pipeline: false ) { + @font-face { + font-family: $font-family; + font-weight: $weight; + font-style: $style; + + @if $asset-pipeline == true { + src: font-url('#{$file-path}.eot'); + src: font-url('#{$file-path}.eot?#iefix') format('embedded-opentype'), + font-url('#{$file-path}.woff') format('woff'), + font-url('#{$file-path}.ttf') format('truetype'), + font-url('#{$file-path}.svg##{$font-family}') format('svg'); + } @else { + src: url('#{$file-path}.eot'); + src: url('#{$file-path}.eot?#iefix') format('embedded-opentype'), + url('#{$file-path}.woff') format('woff'), + url('#{$file-path}.ttf') format('truetype'), + url('#{$file-path}.svg##{$font-family}') format('svg'); + } + } +} diff --git a/common/static/sass/bourbon/css3/_hidpi-media-query.scss b/common/static/sass/bourbon/css3/_hidpi-media-query.scss new file mode 100644 index 0000000000..111e4009b5 --- /dev/null +++ b/common/static/sass/bourbon/css3/_hidpi-media-query.scss @@ -0,0 +1,10 @@ +// HiDPI mixin. Default value set to 1.3 to target Google Nexus 7 (http://bjango.com/articles/min-device-pixel-ratio/) +@mixin hidpi($ratio: 1.3) { + @media only screen and (-webkit-min-device-pixel-ratio: $ratio), + only screen and (min--moz-device-pixel-ratio: $ratio), + only screen and (-o-min-device-pixel-ratio: #{$ratio}/1), + only screen and (min-resolution: #{round($ratio*96)}dpi), + only screen and (min-resolution: #{$ratio}dppx) { + @content; + } +} diff --git a/common/static/sass/bourbon/css3/_image-rendering.scss b/common/static/sass/bourbon/css3/_image-rendering.scss new file mode 100644 index 0000000000..abc7ee1aa4 --- /dev/null +++ b/common/static/sass/bourbon/css3/_image-rendering.scss @@ -0,0 +1,13 @@ +@mixin image-rendering ($mode:optimizeQuality) { + + @if ($mode == optimize-contrast) { + image-rendering: -moz-crisp-edges; + image-rendering: -o-crisp-edges; + image-rendering: -webkit-optimize-contrast; + image-rendering: optimize-contrast; + } + + @else { + image-rendering: $mode; + } +} diff --git a/common/static/sass/bourbon/css3/_inline-block.scss b/common/static/sass/bourbon/css3/_inline-block.scss index d79a13c851..3272a0010b 100644 --- a/common/static/sass/bourbon/css3/_inline-block.scss +++ b/common/static/sass/bourbon/css3/_inline-block.scss @@ -1,7 +1,5 @@ // Legacy support for inline-block in IE7 (maybe IE6) @mixin inline-block { - display: -moz-inline-box; - -moz-box-orient: vertical; display: inline-block; vertical-align: baseline; zoom: 1; diff --git a/common/static/sass/bourbon/css3/_keyframes.scss b/common/static/sass/bourbon/css3/_keyframes.scss new file mode 100644 index 0000000000..dca61f2a07 --- /dev/null +++ b/common/static/sass/bourbon/css3/_keyframes.scss @@ -0,0 +1,43 @@ +// Adds keyframes blocks for supported prefixes, removing redundant prefixes in the block's content +@mixin keyframes($name) { + $original-prefix-for-webkit: $prefix-for-webkit; + $original-prefix-for-mozilla: $prefix-for-mozilla; + $original-prefix-for-microsoft: $prefix-for-microsoft; + $original-prefix-for-opera: $prefix-for-opera; + $original-prefix-for-spec: $prefix-for-spec; + + @if $original-prefix-for-webkit { + @include disable-prefix-for-all(); + $prefix-for-webkit: true; + @-webkit-keyframes #{$name} { + @content; + } + } + @if $original-prefix-for-mozilla { + @include disable-prefix-for-all(); + $prefix-for-mozilla: true; + @-moz-keyframes #{$name} { + @content; + } + } + @if $original-prefix-for-opera { + @include disable-prefix-for-all(); + $prefix-for-opera: true; + @-o-keyframes #{$name} { + @content; + } + } + @if $original-prefix-for-spec { + @include disable-prefix-for-all(); + $prefix-for-spec: true; + @keyframes #{$name} { + @content; + } + } + + $prefix-for-webkit: $original-prefix-for-webkit; + $prefix-for-mozilla: $original-prefix-for-mozilla; + $prefix-for-microsoft: $original-prefix-for-microsoft; + $prefix-for-opera: $original-prefix-for-opera; + $prefix-for-spec: $original-prefix-for-spec; +} diff --git a/common/static/sass/bourbon/css3/_linear-gradient.scss b/common/static/sass/bourbon/css3/_linear-gradient.scss index e366a299a9..d5b687b00c 100644 --- a/common/static/sass/bourbon/css3/_linear-gradient.scss +++ b/common/static/sass/bourbon/css3/_linear-gradient.scss @@ -3,15 +3,25 @@ $G5: false, $G6: false, $G7: false, $G8: false, $G9: false, $G10: false, + $deprecated-pos1: left top, + $deprecated-pos2: left bottom, $fallback: false) { // Detect what type of value exists in $pos $pos-type: type-of(nth($pos, 1)); + $pos-spec: null; + $pos-degree: null; // If $pos is missing from mixin, reassign vars and add default position @if ($pos-type == color) or (nth($pos, 1) == "transparent") { $G10: $G9; $G9: $G8; $G8: $G7; $G7: $G6; $G6: $G5; $G5: $G4; $G4: $G3; $G3: $G2; $G2: $G1; $G1: $pos; - $pos: top; // Default position + $pos: null; + } + + @if $pos { + $positions: _linear-positions-parser($pos); + $pos-degree: nth($positions, 1); + $pos-spec: nth($positions, 2); } $full: compact($G1, $G2, $G3, $G4, $G5, $G6, $G7, $G8, $G9, $G10); @@ -25,17 +35,7 @@ } background-color: $fallback-color; - background-image: deprecated-webkit-gradient(linear, $full); // Safari <= 5.0 - background-image: -webkit-linear-gradient($pos, $full); // Safari 5.1+, Chrome - background-image: -moz-linear-gradient($pos, $full); - background-image: -ms-linear-gradient($pos, $full); - background-image: -o-linear-gradient($pos, $full); - background-image: unquote("linear-gradient(#{$pos}, #{$full})"); + background-image: _deprecated-webkit-gradient(linear, $deprecated-pos1, $deprecated-pos2, $full); // Safari <= 5.0 + background-image: -webkit-linear-gradient($pos-degree $full); // Safari 5.1+, Chrome + background-image: unquote("linear-gradient(#{$pos-spec}#{$full})"); } - - -// Usage: Gradient position is optional, default is top. Position can be a degree. Color stops are optional as well. -// @include linear-gradient(#1e5799, #2989d8); -// @include linear-gradient(#1e5799, #2989d8, $fallback:#2989d8); -// @include linear-gradient(top, #1e5799 0%, #2989d8 50%); -// @include linear-gradient(50deg, rgba(10, 10, 10, 0.5) 0%, #2989d8 50%, #207cca 51%, #7db9e8 100%); diff --git a/common/static/sass/bourbon/css3/_perspective.scss b/common/static/sass/bourbon/css3/_perspective.scss new file mode 100644 index 0000000000..0e4deb80f3 --- /dev/null +++ b/common/static/sass/bourbon/css3/_perspective.scss @@ -0,0 +1,8 @@ +@mixin perspective($depth: none) { + // none | + @include prefixer(perspective, $depth, webkit moz spec); +} + +@mixin perspective-origin($value: 50% 50%) { + @include prefixer(perspective-origin, $value, webkit moz spec); +} diff --git a/common/static/sass/bourbon/css3/_placeholder.scss b/common/static/sass/bourbon/css3/_placeholder.scss new file mode 100644 index 0000000000..22fd92b4f2 --- /dev/null +++ b/common/static/sass/bourbon/css3/_placeholder.scss @@ -0,0 +1,29 @@ +$placeholders: '-webkit-input-placeholder', + '-moz-placeholder', + '-ms-input-placeholder'; + +@mixin placeholder { + @each $placeholder in $placeholders { + @if $placeholder == "-webkit-input-placeholder" { + &::#{$placeholder} { + @content; + } + } + @else if $placeholder == "-moz-placeholder" { + // FF 18- + &:#{$placeholder} { + @content; + } + + // FF 19+ + &::#{$placeholder} { + @content; + } + } + @else { + &:#{$placeholder} { + @content; + } + } + } +} diff --git a/common/static/sass/bourbon/css3/_radial-gradient.scss b/common/static/sass/bourbon/css3/_radial-gradient.scss index e83cab5234..e87b45a5a1 100644 --- a/common/static/sass/bourbon/css3/_radial-gradient.scss +++ b/common/static/sass/bourbon/css3/_radial-gradient.scss @@ -1,31 +1,44 @@ // Requires Sass 3.1+ -@mixin radial-gradient($pos, $shape-size, - $G1, $G2, +@mixin radial-gradient($G1, $G2, $G3: false, $G4: false, $G5: false, $G6: false, $G7: false, $G8: false, $G9: false, $G10: false, + $pos: null, + $shape-size: null, + $deprecated-pos1: center center, + $deprecated-pos2: center center, + $deprecated-radius1: 0, + $deprecated-radius2: 460, $fallback: false) { + $data: _radial-arg-parser($G1, $G2, $pos, $shape-size); + $G1: nth($data, 1); + $G2: nth($data, 2); + $pos: nth($data, 3); + $shape-size: nth($data, 4); + $full: compact($G1, $G2, $G3, $G4, $G5, $G6, $G7, $G8, $G9, $G10); - // Set $G1 as the default fallback color - $fallback-color: nth($G1, 1); + // Strip deprecated cover/contain for spec + $shape-size-spec: _shape-size-stripper($shape-size); + + // Set $G1 as the default fallback color + $first-color: nth($full, 1); + $fallback-color: nth($first-color, 1); - // If $fallback is a color use that color as the fallback color @if (type-of($fallback) == color) or ($fallback == "transparent") { $fallback-color: $fallback; } - background-color: $fallback-color; - background-image: deprecated-webkit-gradient(radial, $full); // Safari <= 5.0 - background-image: -webkit-radial-gradient($pos, $shape-size, $full); - background-image: -moz-radial-gradient($pos, $shape-size, $full); - background-image: -ms-radial-gradient($pos, $shape-size, $full); - background-image: -o-radial-gradient($pos, $shape-size, $full); - background-image: unquote("radial-gradient(#{$pos}, #{$shape-size}, #{$full})"); -} + // Add Commas and spaces + $shape-size: if($shape-size, '#{$shape-size}, ', null); + $pos: if($pos, '#{$pos}, ', null); + $pos-spec: if($pos, 'at #{$pos}', null); + $shape-size-spec: if(($shape-size-spec != ' ') and ($pos == null), '#{$shape-size-spec}, ', '#{$shape-size-spec} '); -// Usage: Gradient position and shape-size are required. Color stops are optional. -// @include radial-gradient(50% 50%, circle cover, #1e5799, #efefef); -// @include radial-gradient(50% 50%, circle cover, #eee 10%, #1e5799 30%, #efefef); + background-color: $fallback-color; + background-image: _deprecated-webkit-gradient(radial, $deprecated-pos1, $deprecated-pos2, $full, $deprecated-radius1, $deprecated-radius2); // Safari <= 5.0 && IOS 4 + background-image: -webkit-radial-gradient(unquote(#{$pos}#{$shape-size}#{$full})); + background-image: unquote("radial-gradient(#{$shape-size-spec}#{$pos-spec}#{$full})"); +} diff --git a/common/static/sass/bourbon/css3/_transform.scss b/common/static/sass/bourbon/css3/_transform.scss index 8d19e8b88d..8cc35963d5 100644 --- a/common/static/sass/bourbon/css3/_transform.scss +++ b/common/static/sass/bourbon/css3/_transform.scss @@ -1,19 +1,15 @@ @mixin transform($property: none) { // none | - -webkit-transform: $property; - -moz-transform: $property; - -ms-transform: $property; - -o-transform: $property; - transform: $property; + @include prefixer(transform, $property, webkit moz ms o spec); } @mixin transform-origin($axes: 50%) { // x-axis - left | center | right | length | % // y-axis - top | center | bottom | length | % // z-axis - length - -webkit-transform-origin: $axes; - -moz-transform-origin: $axes; - -ms-transform-origin: $axes; - -o-transform-origin: $axes; - transform-origin: $axes; + @include prefixer(transform-origin, $axes, webkit moz ms o spec); +} + +@mixin transform-style ($style: flat) { + @include prefixer(transform-style, $style, webkit moz ms o spec); } diff --git a/common/static/sass/bourbon/css3/_transition.scss b/common/static/sass/bourbon/css3/_transition.scss index 058dbe0e33..180cde6c8a 100644 --- a/common/static/sass/bourbon/css3/_transition.scss +++ b/common/static/sass/bourbon/css3/_transition.scss @@ -3,102 +3,32 @@ // @include transition ((opacity, width), (1.0s, 2.0s), ease-in, (0, 2s)); // @include transition ($property:(opacity, width), $delay: (1.5s, 2.5s)); -@mixin transition ($property: all, $duration: 0.15s, $timing-function: ease-out, $delay: 0) { - - // Detect # of args passed into each variable - $length-of-property: length($property); - $length-of-duration: length($duration); - $length-of-timing-function: length($timing-function); - $length-of-delay: length($delay); - - @if $length-of-property > 1 { - @include transition-property(zip($property)); } - @else { - @include transition-property( $property); +@mixin transition ($properties...) { + @if length($properties) >= 1 { + @include prefixer(transition, $properties, webkit moz spec); } - @if $length-of-duration > 1 { - @include transition-duration(zip($duration)); } @else { - @include transition-duration( $duration); - } - - @if $length-of-timing-function > 1 { - @include transition-timing-function(zip($timing-function)); } - @else { - @include transition-timing-function( $timing-function); - } - - @if $length-of-delay > 1 { - @include transition-delay(zip($delay)); } - @else { - @include transition-delay( $delay); + $properties: all 0.15s ease-out 0; + @include prefixer(transition, $properties, webkit moz spec); } } - -@mixin transition-property ($prop-1: all, - $prop-2: false, $prop-3: false, - $prop-4: false, $prop-5: false, - $prop-6: false, $prop-7: false, - $prop-8: false, $prop-9: false) - { - $full: compact($prop-1, $prop-2, $prop-3, $prop-4, $prop-5, - $prop-6, $prop-7, $prop-8, $prop-9); - - -webkit-transition-property: $full; - -moz-transition-property: $full; - -ms-transition-property: $full; - -o-transition-property: $full; - transition-property: $full; +@mixin transition-property ($properties...) { + -webkit-transition-property: transition-property-names($properties, 'webkit'); + -moz-transition-property: transition-property-names($properties, 'moz'); + transition-property: transition-property-names($properties, false); } -@mixin transition-duration ($time-1: 0, - $time-2: false, $time-3: false, - $time-4: false, $time-5: false, - $time-6: false, $time-7: false, - $time-8: false, $time-9: false) - { - $full: compact($time-1, $time-2, $time-3, $time-4, $time-5, - $time-6, $time-7, $time-8, $time-9); - - -webkit-transition-duration: $full; - -moz-transition-duration: $full; - -ms-transition-duration: $full; - -o-transition-duration: $full; - transition-duration: $full; +@mixin transition-duration ($times...) { + @include prefixer(transition-duration, $times, webkit moz spec); } -@mixin transition-timing-function ($motion-1: ease, - $motion-2: false, $motion-3: false, - $motion-4: false, $motion-5: false, - $motion-6: false, $motion-7: false, - $motion-8: false, $motion-9: false) - { - $full: compact($motion-1, $motion-2, $motion-3, $motion-4, $motion-5, - $motion-6, $motion-7, $motion-8, $motion-9); - +@mixin transition-timing-function ($motions...) { // ease | linear | ease-in | ease-out | ease-in-out | cubic-bezier() - -webkit-transition-timing-function: $full; - -moz-transition-timing-function: $full; - -ms-transition-timing-function: $full; - -o-transition-timing-function: $full; - transition-timing-function: $full; + @include prefixer(transition-timing-function, $motions, webkit moz spec); } -@mixin transition-delay ($time-1: 0, - $time-2: false, $time-3: false, - $time-4: false, $time-5: false, - $time-6: false, $time-7: false, - $time-8: false, $time-9: false) - { - $full: compact($time-1, $time-2, $time-3, $time-4, $time-5, - $time-6, $time-7, $time-8, $time-9); - - -webkit-transition-delay: $full; - -moz-transition-delay: $full; - -ms-transition-delay: $full; - -o-transition-delay: $full; - transition-delay: $full; +@mixin transition-delay ($times...) { + @include prefixer(transition-delay, $times, webkit moz spec); } - diff --git a/common/static/sass/bourbon/css3/_user-select.scss b/common/static/sass/bourbon/css3/_user-select.scss index d5f5749431..1380aa8baa 100644 --- a/common/static/sass/bourbon/css3/_user-select.scss +++ b/common/static/sass/bourbon/css3/_user-select.scss @@ -1,6 +1,3 @@ @mixin user-select($arg: none) { - -webkit-user-select: $arg; - -moz-user-select: $arg; - -ms-user-select: $arg; - user-select: $arg; + @include prefixer(user-select, $arg, webkit moz ms spec); } diff --git a/common/static/sass/bourbon/functions/_compact.scss b/common/static/sass/bourbon/functions/_compact.scss new file mode 100644 index 0000000000..871500e339 --- /dev/null +++ b/common/static/sass/bourbon/functions/_compact.scss @@ -0,0 +1,11 @@ +// Remove `false` values from a list + +@function compact($vars...) { + $list: (); + @each $var in $vars { + @if $var { + $list: append($list, $var, comma); + } + } + @return $list; +} diff --git a/common/static/sass/bourbon/functions/_flex-grid.scss b/common/static/sass/bourbon/functions/_flex-grid.scss index 707f994e15..3bbd866573 100644 --- a/common/static/sass/bourbon/functions/_flex-grid.scss +++ b/common/static/sass/bourbon/functions/_flex-grid.scss @@ -14,13 +14,17 @@ // The $fg-column, $fg-gutter and $fg-max-columns variables must be defined in your base stylesheet to properly use the flex-grid function. // This function takes the fluid grid equation (target / context = result) and uses columns to help define each. // +// The calculation presumes that your column structure will be missing the last gutter: +// +// -- column -- gutter -- column -- gutter -- column +// // $fg-column: 60px; // Column Width // $fg-gutter: 25px; // Gutter Width // $fg-max-columns: 12; // Total Columns For Main Container // // div { -// width: flex-grid(4); // returns (315px / 1020px) = 30.882353%; -// margin-left: flex-gutter(); // returns (25px / 1020px) = 2.45098%; +// width: flex-grid(4); // returns (315px / 995px) = 31.65829%; +// margin-left: flex-gutter(); // returns (25px / 995px) = 2.51256%; // // p { // width: flex-grid(2, 4); // returns (145px / 315px) = 46.031746%; @@ -32,4 +36,4 @@ // float: left; // width: flex-grid(2, 4); // returns (145px / 315px) = 46.031746%; // } -// } +// } \ No newline at end of file diff --git a/common/static/sass/bourbon/functions/_linear-gradient.scss b/common/static/sass/bourbon/functions/_linear-gradient.scss index 3b10ca82a6..c8454d83f0 100644 --- a/common/static/sass/bourbon/functions/_linear-gradient.scss +++ b/common/static/sass/bourbon/functions/_linear-gradient.scss @@ -1,23 +1,13 @@ -@function linear-gradient($pos: top, $G1: false, $G2: false, - $G3: false, $G4: false, - $G5: false, $G6: false, - $G7: false, $G8: false, - $G9: false, $G10: false) { - - // Detect what type of value exists in $pos +@function linear-gradient($pos, $gradients...) { + $type: linear; $pos-type: type-of(nth($pos, 1)); - // If $pos is missing from mixin, reassign vars and add default position + // if $pos doesn't exist, fix $gradient @if ($pos-type == color) or (nth($pos, 1) == "transparent") { - $G10: $G9; $G9: $G8; $G8: $G7; $G7: $G6; $G6: $G5; - $G5: $G4; $G4: $G3; $G3: $G2; $G2: $G1; $G1: $pos; - $pos: top; // Default position + $gradients: zip($pos $gradients); + $pos: false; } - $type: linear; - $gradient: compact($pos, $G1, $G2, $G3, $G4, $G5, $G6, $G7, $G8, $G9, $G10); - $type-gradient: append($type, $gradient, comma); - + $type-gradient: $type, $pos, $gradients; @return $type-gradient; } - diff --git a/common/static/sass/bourbon/functions/_px-to-em.scss b/common/static/sass/bourbon/functions/_px-to-em.scss new file mode 100644 index 0000000000..2eb1031c60 --- /dev/null +++ b/common/static/sass/bourbon/functions/_px-to-em.scss @@ -0,0 +1,8 @@ +// Convert pixels to ems +// eg. for a relational value of 12px write em(12) when the parent is 16px +// if the parent is another value say 24px write em(12, 24) + +@function em($pxval, $base: 16) { + @return ($pxval / $base) * 1em; +} + diff --git a/common/static/sass/bourbon/functions/_radial-gradient.scss b/common/static/sass/bourbon/functions/_radial-gradient.scss index 3d5461ad6e..75584060d2 100644 --- a/common/static/sass/bourbon/functions/_radial-gradient.scss +++ b/common/static/sass/bourbon/functions/_radial-gradient.scss @@ -1,15 +1,23 @@ // This function is required and used by the background-image mixin. -@function radial-gradient($pos, $shape-size, - $G1, $G2, +@function radial-gradient($G1, $G2, $G3: false, $G4: false, $G5: false, $G6: false, $G7: false, $G8: false, - $G9: false, $G10: false) { + $G9: false, $G10: false, + $pos: null, + $shape-size: null) { + + $data: _radial-arg-parser($G1, $G2, $pos, $shape-size); + $G1: nth($data, 1); + $G2: nth($data, 2); + $pos: nth($data, 3); + $shape-size: nth($data, 4); $type: radial; - $gradient: compact($pos, $shape-size, $G1, $G2, $G3, $G4, $G5, $G6, $G7, $G8, $G9, $G10); - $type-gradient: append($type, $gradient, comma); + $gradient: compact($G1, $G2, $G3, $G4, $G5, $G6, $G7, $G8, $G9, $G10); + $type-gradient: $type, $shape-size $pos, $gradient; @return $type-gradient; } + diff --git a/common/static/sass/bourbon/functions/_render-gradients.scss b/common/static/sass/bourbon/functions/_render-gradients.scss deleted file mode 100644 index fe7c799ebe..0000000000 --- a/common/static/sass/bourbon/functions/_render-gradients.scss +++ /dev/null @@ -1,14 +0,0 @@ -// User for linear and radial gradients within background-image or border-image properties - -@function render-gradients($gradients, $gradient-type, $vendor: false) { - $vendor-gradients: false; - @if $vendor { - $vendor-gradients: -#{$vendor}-#{$gradient-type}-gradient($gradients); - } - - @else if $vendor == false { - $vendor-gradients: "#{$gradient-type}-gradient(#{$gradients})"; - $vendor-gradients: unquote($vendor-gradients); - } - @return $vendor-gradients; -} diff --git a/common/static/sass/bourbon/functions/_transition-property-name.scss b/common/static/sass/bourbon/functions/_transition-property-name.scss new file mode 100644 index 0000000000..54cd422811 --- /dev/null +++ b/common/static/sass/bourbon/functions/_transition-property-name.scss @@ -0,0 +1,22 @@ +// Return vendor-prefixed property names if appropriate +// Example: transition-property-names((transform, color, background), moz) -> -moz-transform, color, background +//************************************************************************// +@function transition-property-names($props, $vendor: false) { + $new-props: (); + + @each $prop in $props { + $new-props: append($new-props, transition-property-name($prop, $vendor), comma); + } + + @return $new-props; +} + +@function transition-property-name($prop, $vendor: false) { + // put other properties that need to be prefixed here aswell + @if $vendor and $prop == transform { + @return unquote('-'+$vendor+'-'+$prop); + } + @else { + @return $prop; + } +} \ No newline at end of file diff --git a/common/static/sass/bourbon/functions/_deprecated-webkit-gradient.scss b/common/static/sass/bourbon/helpers/_deprecated-webkit-gradient.scss similarity index 62% rename from common/static/sass/bourbon/functions/_deprecated-webkit-gradient.scss rename to common/static/sass/bourbon/helpers/_deprecated-webkit-gradient.scss index 1322f6f60e..cd17e2832d 100644 --- a/common/static/sass/bourbon/functions/_deprecated-webkit-gradient.scss +++ b/common/static/sass/bourbon/helpers/_deprecated-webkit-gradient.scss @@ -1,6 +1,9 @@ // Render Deprecated Webkit Gradient - Linear || Radial //************************************************************************// -@function deprecated-webkit-gradient($type, $full) { +@function _deprecated-webkit-gradient($type, + $deprecated-pos1, $deprecated-pos2, + $full, + $deprecated-radius1: false, $deprecated-radius2: false) { $gradient-list: (); $gradient: false; $full-length: length($full); @@ -14,7 +17,7 @@ $color-stop: color-stop(nth($gradient, 2), nth($gradient, 1)); $gradient-list: join($gradient-list, $color-stop, comma); } - @else { + @else if $gradient != null { @if $i == $full-length { $percentage: 100%; } @@ -27,10 +30,10 @@ } @if $type == radial { - $gradient: -webkit-gradient(radial, center center, 0, center center, 460, $gradient-list); + $gradient: -webkit-gradient(radial, $deprecated-pos1, $deprecated-radius1, $deprecated-pos2, $deprecated-radius2, $gradient-list); } @else if $type == linear { - $gradient: -webkit-gradient(linear, left top, left bottom, $gradient-list); + $gradient: -webkit-gradient(linear, $deprecated-pos1, $deprecated-pos2, $gradient-list); } @return $gradient; } diff --git a/common/static/sass/bourbon/helpers/_gradient-positions-parser.scss b/common/static/sass/bourbon/helpers/_gradient-positions-parser.scss new file mode 100644 index 0000000000..07d30b6cf9 --- /dev/null +++ b/common/static/sass/bourbon/helpers/_gradient-positions-parser.scss @@ -0,0 +1,13 @@ +@function _gradient-positions-parser($gradient-type, $gradient-positions) { + @if $gradient-positions + and ($gradient-type == linear) + and (type-of($gradient-positions) != color) { + $gradient-positions: _linear-positions-parser($gradient-positions); + } + @else if $gradient-positions + and ($gradient-type == radial) + and (type-of($gradient-positions) != color) { + $gradient-positions: _radial-positions-parser($gradient-positions); + } + @return $gradient-positions; +} diff --git a/common/static/sass/bourbon/helpers/_linear-positions-parser.scss b/common/static/sass/bourbon/helpers/_linear-positions-parser.scss new file mode 100644 index 0000000000..d26383edce --- /dev/null +++ b/common/static/sass/bourbon/helpers/_linear-positions-parser.scss @@ -0,0 +1,61 @@ +@function _linear-positions-parser($pos) { + $type: type-of(nth($pos, 1)); + $spec: null; + $degree: null; + $side: null; + $corner: null; + $length: length($pos); + // Parse Side and corner positions + @if ($length > 1) { + @if nth($pos, 1) == "to" { // Newer syntax + $side: nth($pos, 2); + + @if $length == 2 { // eg. to top + // Swap for backwards compatability + $degree: _position-flipper(nth($pos, 2)); + } + @else if $length == 3 { // eg. to top left + $corner: nth($pos, 3); + } + } + @else if $length == 2 { // Older syntax ("top left") + $side: _position-flipper(nth($pos, 1)); + $corner: _position-flipper(nth($pos, 2)); + } + + @if ("#{$side} #{$corner}" == "left top") or ("#{$side} #{$corner}" == "top left") { + $degree: _position-flipper(#{$side}) _position-flipper(#{$corner}); + } + @else if ("#{$side} #{$corner}" == "right top") or ("#{$side} #{$corner}" == "top right") { + $degree: _position-flipper(#{$side}) _position-flipper(#{$corner}); + } + @else if ("#{$side} #{$corner}" == "right bottom") or ("#{$side} #{$corner}" == "bottom right") { + $degree: _position-flipper(#{$side}) _position-flipper(#{$corner}); + } + @else if ("#{$side} #{$corner}" == "left bottom") or ("#{$side} #{$corner}" == "bottom left") { + $degree: _position-flipper(#{$side}) _position-flipper(#{$corner}); + } + $spec: to $side $corner; + } + @else if $length == 1 { + // Swap for backwards compatability + @if $type == string { + $degree: $pos; + $spec: to _position-flipper($pos); + } + @else { + $degree: -270 - $pos; //rotate the gradient opposite from spec + $spec: $pos; + } + } + $degree: unquote($degree + ","); + $spec: unquote($spec + ","); + @return $degree $spec; +} + +@function _position-flipper($pos) { + @return if($pos == left, right, null) + if($pos == right, left, null) + if($pos == top, bottom, null) + if($pos == bottom, top, null); +} diff --git a/common/static/sass/bourbon/helpers/_radial-arg-parser.scss b/common/static/sass/bourbon/helpers/_radial-arg-parser.scss new file mode 100644 index 0000000000..3466695bdf --- /dev/null +++ b/common/static/sass/bourbon/helpers/_radial-arg-parser.scss @@ -0,0 +1,69 @@ +@function _radial-arg-parser($G1, $G2, $pos, $shape-size) { + @each $value in $G1, $G2 { + $first-val: nth($value, 1); + $pos-type: type-of($first-val); + $spec-at-index: null; + + // Determine if spec was passed to mixin + @if type-of($value) == list { + $spec-at-index: if(index($value, at), index($value, at), false); + } + @if $spec-at-index { + @if $spec-at-index > 1 { + @for $i from 1 through ($spec-at-index - 1) { + $shape-size: $shape-size nth($value, $i); + } + @for $i from ($spec-at-index + 1) through length($value) { + $pos: $pos nth($value, $i); + } + } + @else if $spec-at-index == 1 { + @for $i from ($spec-at-index + 1) through length($value) { + $pos: $pos nth($value, $i); + } + } + $G1: false; + } + + // If not spec calculate correct values + @else { + @if ($pos-type != color) or ($first-val != "transparent") { + @if ($pos-type == number) + or ($first-val == "center") + or ($first-val == "top") + or ($first-val == "right") + or ($first-val == "bottom") + or ($first-val == "left") { + + $pos: $value; + + @if $pos == $G1 { + $G1: false; + } + } + + @else if + ($first-val == "ellipse") + or ($first-val == "circle") + or ($first-val == "closest-side") + or ($first-val == "closest-corner") + or ($first-val == "farthest-side") + or ($first-val == "farthest-corner") + or ($first-val == "contain") + or ($first-val == "cover") { + + $shape-size: $value; + + @if $value == $G1 { + $G1: false; + } + + @else if $value == $G2 { + $G2: false; + } + } + } + } + } + @return $G1, $G2, $pos, $shape-size; +} diff --git a/common/static/sass/bourbon/helpers/_radial-positions-parser.scss b/common/static/sass/bourbon/helpers/_radial-positions-parser.scss new file mode 100644 index 0000000000..6a5b477778 --- /dev/null +++ b/common/static/sass/bourbon/helpers/_radial-positions-parser.scss @@ -0,0 +1,18 @@ +@function _radial-positions-parser($gradient-pos) { + $shape-size: nth($gradient-pos, 1); + $pos: nth($gradient-pos, 2); + $shape-size-spec: _shape-size-stripper($shape-size); + + $pre-spec: unquote(if($pos, "#{$pos}, ", null)) + unquote(if($shape-size, "#{$shape-size},", null)); + $pos-spec: if($pos, "at #{$pos}", null); + + $spec: "#{$shape-size-spec} #{$pos-spec}"; + + // Add comma + @if ($spec != ' ') { + $spec: "#{$spec}," + } + + @return $pre-spec $spec; +} diff --git a/common/static/sass/bourbon/helpers/_render-gradients.scss b/common/static/sass/bourbon/helpers/_render-gradients.scss new file mode 100644 index 0000000000..5765676838 --- /dev/null +++ b/common/static/sass/bourbon/helpers/_render-gradients.scss @@ -0,0 +1,26 @@ +// User for linear and radial gradients within background-image or border-image properties + +@function _render-gradients($gradient-positions, $gradients, $gradient-type, $vendor: false) { + $pre-spec: null; + $spec: null; + $vendor-gradients: null; + @if $gradient-type == linear { + @if $gradient-positions { + $pre-spec: nth($gradient-positions, 1); + $spec: nth($gradient-positions, 2); + } + } + @else if $gradient-type == radial { + $pre-spec: nth($gradient-positions, 1); + $spec: nth($gradient-positions, 2); + } + + @if $vendor { + $vendor-gradients: -#{$vendor}-#{$gradient-type}-gradient(#{$pre-spec} $gradients); + } + @else if $vendor == false { + $vendor-gradients: "#{$gradient-type}-gradient(#{$spec} #{$gradients})"; + $vendor-gradients: unquote($vendor-gradients); + } + @return $vendor-gradients; +} diff --git a/common/static/sass/bourbon/helpers/_shape-size-stripper.scss b/common/static/sass/bourbon/helpers/_shape-size-stripper.scss new file mode 100644 index 0000000000..ee5eda4220 --- /dev/null +++ b/common/static/sass/bourbon/helpers/_shape-size-stripper.scss @@ -0,0 +1,10 @@ +@function _shape-size-stripper($shape-size) { + $shape-size-spec: null; + @each $value in $shape-size { + @if ($value == "cover") or ($value == "contain") { + $value: null; + } + $shape-size-spec: "#{$shape-size-spec} #{$value}"; + } + @return $shape-size-spec; +} diff --git a/common/static/sass/bourbon/lib/bourbon.rb b/common/static/sass/bourbon/lib/bourbon.rb deleted file mode 100644 index 1635be836d..0000000000 --- a/common/static/sass/bourbon/lib/bourbon.rb +++ /dev/null @@ -1,19 +0,0 @@ -require "bourbon/generator" - -module Bourbon - if defined?(Rails) - class Engine < ::Rails::Engine - require 'bourbon/engine' - end - - module Rails - class Railtie < ::Rails::Railtie - rake_tasks do - load "tasks/install.rake" - end - end - end - end -end - -require File.join(File.dirname(__FILE__), "/bourbon/sass_extensions") diff --git a/common/static/sass/bourbon/lib/bourbon/sass_extensions.rb b/common/static/sass/bourbon/lib/bourbon/sass_extensions.rb deleted file mode 100644 index ad567200e3..0000000000 --- a/common/static/sass/bourbon/lib/bourbon/sass_extensions.rb +++ /dev/null @@ -1,6 +0,0 @@ -module Bourbon::SassExtensions -end - -require "sass" - -require File.join(File.dirname(__FILE__), "/sass_extensions/functions") diff --git a/common/static/sass/bourbon/lib/bourbon/sass_extensions/functions.rb b/common/static/sass/bourbon/lib/bourbon/sass_extensions/functions.rb deleted file mode 100644 index daa877650e..0000000000 --- a/common/static/sass/bourbon/lib/bourbon/sass_extensions/functions.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Bourbon::SassExtensions::Functions -end - -require File.join(File.dirname(__FILE__), "/functions/compact") - -module Sass::Script::Functions - include Bourbon::SassExtensions::Functions::Compact -end - -# Wierd that this has to be re-included to pick up sub-modules. Ruby bug? -class Sass::Script::Functions::EvaluationContext - include Sass::Script::Functions -end diff --git a/common/static/sass/bourbon/lib/bourbon/sass_extensions/functions/compact.rb b/common/static/sass/bourbon/lib/bourbon/sass_extensions/functions/compact.rb deleted file mode 100644 index 5192e921e7..0000000000 --- a/common/static/sass/bourbon/lib/bourbon/sass_extensions/functions/compact.rb +++ /dev/null @@ -1,13 +0,0 @@ -# Compact function pulled from compass -module Bourbon::SassExtensions::Functions::Compact - - def compact(*args) - sep = :comma - if args.size == 1 && args.first.is_a?(Sass::Script::List) - args = args.first.value - sep = args.first.separator - end - Sass::Script::List.new(args.reject{|a| !a.to_bool}, sep) - end - -end diff --git a/common/static/sass/neat/_neat-helpers.scss b/common/static/sass/neat/_neat-helpers.scss new file mode 100644 index 0000000000..86021b1bff --- /dev/null +++ b/common/static/sass/neat/_neat-helpers.scss @@ -0,0 +1,8 @@ +// Functions +@import "functions/private"; +@import "functions/new-breakpoint"; +@import "functions/px-to-em"; + +// Settings +@import "settings/grid"; +@import "settings/visual-grid"; diff --git a/common/static/sass/neat/_neat.scss b/common/static/sass/neat/_neat.scss new file mode 100644 index 0000000000..cb5876b82d --- /dev/null +++ b/common/static/sass/neat/_neat.scss @@ -0,0 +1,21 @@ +// Bourbon Neat +// MIT Licensed +// Copyright (c) 2012-2013 thoughtbot, inc. + +// Helpers +@import "neat-helpers"; + +// Grid +@import "grid/private"; +@import "grid/reset"; +@import "grid/grid"; +@import "grid/omega"; +@import "grid/outer-container"; +@import "grid/span-columns"; +@import "grid/row"; +@import "grid/shift"; +@import "grid/pad"; +@import "grid/fill-parent"; +@import "grid/media"; +@import "grid/to-deprecate"; +@import "grid/visual-grid"; diff --git a/common/static/sass/neat/functions/_new-breakpoint.scss b/common/static/sass/neat/functions/_new-breakpoint.scss new file mode 100644 index 0000000000..d89dcd101b --- /dev/null +++ b/common/static/sass/neat/functions/_new-breakpoint.scss @@ -0,0 +1,16 @@ +@function new-breakpoint($query:$feature $value $columns, $total-columns: $grid-columns) { + + @if length($query) == 1 { + $query: $default-feature nth($query, 1) $total-columns; + } + + @else if length($query) == 2 or length($query) == 4 { + $query: append($query, $total-columns); + } + + @if not belongs-to($query, $visual-grid-breakpoints) { + $visual-grid-breakpoints: append($visual-grid-breakpoints, $query, comma); + } + + @return $query; +} diff --git a/common/static/sass/neat/functions/_private.scss b/common/static/sass/neat/functions/_private.scss new file mode 100644 index 0000000000..136a6ff3a1 --- /dev/null +++ b/common/static/sass/neat/functions/_private.scss @@ -0,0 +1,107 @@ +// Checks if a number is even +@function is-even($int) { + @if $int%2 == 0 { + @return true; + } + + @return false; +} + +// Checks if an element belongs to a list +@function belongs-to($tested-item, $list) { + @each $item in $list { + @if $item == $tested-item { + @return true; + } + } + + @return false; +} + +// Contains display value +@function contains-display-value($query) { + @if belongs-to(table, $query) or belongs-to(block, $query) or belongs-to(inline-block, $query) or belongs-to(inline, $query) { + @return true; + } + + @return false; +} + +// Parses the first argument of span-columns() +@function container-span($span: $span) { + @if length($span) == 3 { + $container-columns: nth($span, 3); + @return $container-columns; + } + + @else if length($span) == 2 { + $container-columns: nth($span, 2); + @return $container-columns; + } + + @else { + @return $grid-columns; + } +} + +// Generates a striped background +@function gradient-stops($grid-columns, $color: $visual-grid-color) { + $transparent: rgba(0,0,0,0); + + $column-width: flex-grid(1, $grid-columns); + $gutter-width: flex-gutter($grid-columns); + $column-offset: $column-width; + + $values: ($transparent 0, $color 0); + + @for $i from 1 to $grid-columns*2 { + @if is-even($i) { + $values: append($values, $transparent $column-offset); + $values: append($values, $color $column-offset); + $column-offset: $column-offset + $column-width; + } + + @else { + $values: append($values, $color $column-offset); + $values: append($values, $transparent $column-offset); + $column-offset: $column-offset + $gutter-width; + } + } + + @return $values; +} + +// Layout direction +@function get-direction($layout, $default) { + $direction: nil; + + @if $layout == LTR or $layout == RTL { + $direction: direction-from-layout($layout); + } @else { + $direction: direction-from-layout($default); + } + + @return $direction; +} + +@function direction-from-layout($layout) { + $direction: nil; + + @if $layout == LTR { + $direction: right; + } @else { + $direction: left; + } + + @return $direction; +} + +@function get-opposite-direction($direction) { + $opposite-direction: left; + + @if $direction == left { + $opposite-direction: right; + } + + @return $opposite-direction; +} diff --git a/common/static/sass/neat/functions/_px-to-em.scss b/common/static/sass/neat/functions/_px-to-em.scss new file mode 100644 index 0000000000..058e51e8b5 --- /dev/null +++ b/common/static/sass/neat/functions/_px-to-em.scss @@ -0,0 +1,3 @@ +@function em($pxval, $base: 16) { + @return ($pxval / $base) * 1em; +} diff --git a/common/static/sass/neat/grid/_fill-parent.scss b/common/static/sass/neat/grid/_fill-parent.scss new file mode 100644 index 0000000000..859c97790b --- /dev/null +++ b/common/static/sass/neat/grid/_fill-parent.scss @@ -0,0 +1,7 @@ +@mixin fill-parent() { + width: 100%; + + @if $border-box-sizing == false { + @include box-sizing(border-box); + } +} diff --git a/common/static/sass/neat/grid/_grid.scss b/common/static/sass/neat/grid/_grid.scss new file mode 100644 index 0000000000..e074b6c536 --- /dev/null +++ b/common/static/sass/neat/grid/_grid.scss @@ -0,0 +1,5 @@ +@if $border-box-sizing == true { + * { + @include box-sizing(border-box); + } +} diff --git a/common/static/sass/neat/grid/_media.scss b/common/static/sass/neat/grid/_media.scss new file mode 100644 index 0000000000..7c9872fb52 --- /dev/null +++ b/common/static/sass/neat/grid/_media.scss @@ -0,0 +1,51 @@ +@mixin media($query:$feature $value $columns, $total-columns: $grid-columns) { + + @if length($query) == 1 { + @media screen and ($default-feature: nth($query, 1)) { + $default-grid-columns: $grid-columns; + $grid-columns: $total-columns; + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 2 { + @media screen and (nth($query, 1): nth($query, 2)) { + $default-grid-columns: $grid-columns; + $grid-columns: $total-columns; + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 3 { + @media screen and (nth($query, 1): nth($query, 2)) { + $default-grid-columns: $grid-columns; + $grid-columns: nth($query, 3); + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 4 { + @media screen and (nth($query, 1): nth($query, 2)) and (nth($query, 3): nth($query, 4)) { + $default-grid-columns: $grid-columns; + $grid-columns: $total-columns; + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 5 { + @media screen and (nth($query, 1): nth($query, 2)) and (nth($query, 3): nth($query, 4)) { + $default-grid-columns: $grid-columns; + $grid-columns: nth($query, 5); + @content; + $grid-columns: $default-grid-columns; + } + } + + @else { + @warn "Wrong number of arguments for breakpoint(). Read the documentation for more details."; + } +} diff --git a/common/static/sass/neat/grid/_omega.scss b/common/static/sass/neat/grid/_omega.scss new file mode 100644 index 0000000000..902459bcbc --- /dev/null +++ b/common/static/sass/neat/grid/_omega.scss @@ -0,0 +1,79 @@ +// Remove last element gutter +@mixin omega($query: block, $direction: default) { + $table: if(belongs-to(table, $query), true, false); + $auto: if(belongs-to(auto, $query), true, false); + + @if $direction != default { + @warn "The omega mixin will no longer take a $direction argument. To change the layout direction, use row($direction) or set $default-layout-direction instead." + } @else { + $direction: get-direction($layout-direction, $default-layout-direction); + } + + @if length($query) == 1 { + @if $auto { + &:last-child { + margin-#{$direction}: 0; + } + } + + @else if contains-display-value($query) { + @if $table { + padding-#{$direction}: 0; + } + + @else { + margin-#{$direction}: 0; + } + } + + @else { + @include nth-child($query, $direction); + } + } + + @else if length($query) == 2 { + @if $table { + @if $auto { + &:last-child { + padding-#{$direction}: 0; + } + } + + @else { + &:nth-child(#{nth($query, 1)}) { + padding-#{$direction}: 0; + } + } + } + + @else { + @if $auto { + &:last-child { + margin-#{$direction}: 0; + } + } + + @else { + @include nth-child(nth($query, 1), $direction); + } + } + } + + @else { + @warn "Too many arguments passed to the omega() mixin." + } +} + +@mixin nth-child($query, $direction) { + $opposite-direction: get-opposite-direction($direction); + + &:nth-child(#{$query}) { + margin-#{$direction}: 0; + } + + @if type-of($query) == number { + &:nth-child(#{$query}+1) { + clear: $opposite-direction; + } + } +} diff --git a/common/static/sass/neat/grid/_outer-container.scss b/common/static/sass/neat/grid/_outer-container.scss new file mode 100644 index 0000000000..22c541f455 --- /dev/null +++ b/common/static/sass/neat/grid/_outer-container.scss @@ -0,0 +1,8 @@ +@mixin outer-container { + @include clearfix; + max-width: $max-width; + margin: { + left: auto; + right: auto; + } +} diff --git a/common/static/sass/neat/grid/_pad.scss b/common/static/sass/neat/grid/_pad.scss new file mode 100644 index 0000000000..3ef5d80e45 --- /dev/null +++ b/common/static/sass/neat/grid/_pad.scss @@ -0,0 +1,8 @@ +@mixin pad($padding: flex-gutter()) { + $padding-list: null; + @each $value in $padding { + $value: if($value == 'default', flex-gutter(), $value); + $padding-list: join($padding-list, $value); + } + padding: $padding-list; +} diff --git a/common/static/sass/neat/grid/_private.scss b/common/static/sass/neat/grid/_private.scss new file mode 100644 index 0000000000..acd1b5b74d --- /dev/null +++ b/common/static/sass/neat/grid/_private.scss @@ -0,0 +1,21 @@ +$parent-columns: $grid-columns !default; +$fg-column: $column; +$fg-gutter: $gutter; +$fg-max-columns: $grid-columns; +$container-display-table: false !default; +$layout-direction: nil !default; + +@function flex-grid($columns, $container-columns: $fg-max-columns) { + $width: $columns * $fg-column + ($columns - 1) * $fg-gutter; + $container-width: $container-columns * $fg-column + ($container-columns - 1) * $fg-gutter; + @return percentage($width / $container-width); +} + +@function flex-gutter($container-columns: $fg-max-columns, $gutter: $fg-gutter) { + $container-width: $container-columns * $fg-column + ($container-columns - 1) * $fg-gutter; + @return percentage($gutter / $container-width); +} + +@function grid-width($n) { + @return $n * $gw-column + ($n - 1) * $gw-gutter; +} diff --git a/common/static/sass/neat/grid/_reset.scss b/common/static/sass/neat/grid/_reset.scss new file mode 100644 index 0000000000..f670019e4b --- /dev/null +++ b/common/static/sass/neat/grid/_reset.scss @@ -0,0 +1,12 @@ +@mixin reset-display { + $container-display-table: false; +} + +@mixin reset-layout-direction { + $layout-direction: $default-layout-direction; +} + +@mixin reset-all { + @include reset-display; + @include reset-layout-direction; +} diff --git a/common/static/sass/neat/grid/_row.scss b/common/static/sass/neat/grid/_row.scss new file mode 100644 index 0000000000..582603dd01 --- /dev/null +++ b/common/static/sass/neat/grid/_row.scss @@ -0,0 +1,17 @@ +@mixin row($display: block, $direction: $default-layout-direction) { + @include clearfix; + $layout-direction: $direction; + + @if $display == table { + display: table; + @include fill-parent; + table-layout: fixed; + $container-display-table: true; + } + + @else { + display: block; + $container-display-table: false; + } +} + diff --git a/common/static/sass/neat/grid/_shift.scss b/common/static/sass/neat/grid/_shift.scss new file mode 100644 index 0000000000..e39208ef0d --- /dev/null +++ b/common/static/sass/neat/grid/_shift.scss @@ -0,0 +1,10 @@ +@mixin shift($n-columns: 1) { + + $direction: get-direction($layout-direction, $default-layout-direction); + $opposite-direction: get-opposite-direction($direction); + + margin-#{$opposite-direction}: $n-columns * flex-grid(1, $parent-columns) + $n-columns * flex-gutter($parent-columns); + + // Reset nesting context + $parent-columns: $grid-columns; +} diff --git a/common/static/sass/neat/grid/_span-columns.scss b/common/static/sass/neat/grid/_span-columns.scss new file mode 100644 index 0000000000..97902d62c4 --- /dev/null +++ b/common/static/sass/neat/grid/_span-columns.scss @@ -0,0 +1,45 @@ +@mixin span-columns($span: $columns of $container-columns, $display: block) { + + $columns: nth($span, 1); + $container-columns: container-span($span); + $display-table: false; + + $direction: get-direction($layout-direction, $default-layout-direction); + $opposite-direction: get-opposite-direction($direction); + + @if $container-columns != $grid-columns { + $parent-columns: $container-columns; + } @else { + $parent-columns: $grid-columns; + } + + @if $container-display-table == true { + $display-table: true; + } @else if $display == table { + $display-table: true; + } @else { + $display-table: false; + } + + @if $display-table { + display: table-cell; + padding-#{$direction}: flex-gutter($container-columns); + width: flex-grid($columns, $container-columns) + flex-gutter($container-columns); + + &:last-child { + width: flex-grid($columns, $container-columns); + padding-#{$direction}: 0; + } + } + + @else { + display: block; + float: #{$opposite-direction}; + margin-#{$direction}: flex-gutter($container-columns); + width: flex-grid($columns, $container-columns); + + &:last-child { + margin-#{$direction}: 0; + } + } +} diff --git a/common/static/sass/neat/grid/_to-deprecate.scss b/common/static/sass/neat/grid/_to-deprecate.scss new file mode 100644 index 0000000000..d0a681fd12 --- /dev/null +++ b/common/static/sass/neat/grid/_to-deprecate.scss @@ -0,0 +1,57 @@ +@mixin breakpoint($query:$feature $value $columns, $total-columns: $grid-columns) { + @warn "The breakpoint() mixin was renamed to media() in Neat 1.0. Please update your project with the new syntax before the next version bump."; + + @if length($query) == 1 { + @media screen and ($default-feature: nth($query, 1)) { + $default-grid-columns: $grid-columns; + $grid-columns: $total-columns; + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 2 { + @media screen and (nth($query, 1): nth($query, 2)) { + $default-grid-columns: $grid-columns; + $grid-columns: $total-columns; + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 3 { + @media screen and (nth($query, 1): nth($query, 2)) { + $default-grid-columns: $grid-columns; + $grid-columns: nth($query, 3); + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 4 { + @media screen and (nth($query, 1): nth($query, 2)) and (nth($query, 3): nth($query, 4)) { + $default-grid-columns: $grid-columns; + $grid-columns: $total-columns; + @content; + $grid-columns: $default-grid-columns; + } + } + + @else if length($query) == 5 { + @media screen and (nth($query, 1): nth($query, 2)) and (nth($query, 3): nth($query, 4)) { + $default-grid-columns: $grid-columns; + $grid-columns: nth($query, 5); + @content; + $grid-columns: $default-grid-columns; + } + } + + @else { + @warn "Wrong number of arguments for breakpoint(). Read the documentation for more details."; + } +} + +@mixin nth-omega($nth, $display: block, $direction: default) { + @warn "The nth-omega() mixin is deprecated. Please use omega() instead."; + @include omega($nth $display, $direction); +} diff --git a/common/static/sass/neat/grid/_visual-grid.scss b/common/static/sass/neat/grid/_visual-grid.scss new file mode 100644 index 0000000000..1c822fd322 --- /dev/null +++ b/common/static/sass/neat/grid/_visual-grid.scss @@ -0,0 +1,41 @@ +@mixin grid-column-gradient($values...) { + background-image: deprecated-webkit-gradient(linear, left top, left bottom, $values); + background-image: -webkit-linear-gradient(left, $values); + background-image: -moz-linear-gradient(left, $values); + background-image: -ms-linear-gradient(left, $values); + background-image: -o-linear-gradient(left, $values); + background-image: unquote("linear-gradient(left, #{$values})"); +} + +@if $visual-grid == true or $visual-grid == yes { + body:before { + content: ''; + display: inline-block; + @include grid-column-gradient(gradient-stops($grid-columns)); + height: 100%; + left: 0; + margin: 0 auto; + max-width: $max-width; + opacity: $visual-grid-opacity; + position: fixed; + right: 0; + width: 100%; + pointer-events: none; + + @if $visual-grid-index == back { + z-index: -1; + } + + @else if $visual-grid-index == front { + z-index: 9999; + } + + @each $breakpoint in $visual-grid-breakpoints { + @if $breakpoint != nil { + @include media($breakpoint) { + @include grid-column-gradient(gradient-stops($grid-columns)); + } + } + } + } +} diff --git a/common/static/sass/neat/settings/_grid.scss b/common/static/sass/neat/settings/_grid.scss new file mode 100644 index 0000000000..f1dcda4780 --- /dev/null +++ b/common/static/sass/neat/settings/_grid.scss @@ -0,0 +1,7 @@ +$column: golden-ratio(1em, 3) !default; // Column width +$gutter: golden-ratio(1em, 1) !default; // Gutter between each two columns +$grid-columns: 12 !default; // Total number of columns in the grid +$max-width: em(1088) !default; // Max-width of the outer container +$border-box-sizing: true !default; // Makes all elements have a border-box layout +$default-feature: min-width; // Default @media feature for the breakpoint() mixin +$default-layout-direction: LTR !default; diff --git a/common/static/sass/neat/settings/_visual-grid.scss b/common/static/sass/neat/settings/_visual-grid.scss new file mode 100644 index 0000000000..611c2b3727 --- /dev/null +++ b/common/static/sass/neat/settings/_visual-grid.scss @@ -0,0 +1,5 @@ +$visual-grid: false !default; // Display the base grid +$visual-grid-color: #EEE !default; +$visual-grid-index: back !default; // Show grid behind content (back) or overlay it over the content (front) +$visual-grid-opacity: 0.4 !default; +$visual-grid-breakpoints: () !default; diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html new file mode 100644 index 0000000000..6f5d6f37fb --- /dev/null +++ b/common/templates/hinter_display.html @@ -0,0 +1,130 @@ +## The hinter module passes in a field called ${op}, which determines which +## sub-function to render. + + +<%def name="get_hint()"> + % if best_hint != '': +

Hints from students who made similar mistakes:

+
    +
  • ${best_hint}
  • + % endif + % if rand_hint_1 != '': +
  • ${rand_hint_1}
  • + % endif + % if rand_hint_2 != '': +
  • ${rand_hint_2}
  • + % endif +
+ + +<%def name="get_feedback()"> +

Participation in the hinting system is strictly optional, and will not influence your grade.

+

+ Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below: +

+ +
+
    + % for index, answer in index_to_answer.items(): +
  • ${answer}
  • + % endfor +
+ + % for index, answer in index_to_answer.items(): +
+
+ % if index in index_to_hints and len(index_to_hints[index]) > 0: +

+ Which hint would be most effective to show a student who also got ${answer}? +

+ % for hint_text, hint_pk in index_to_hints[index]: +

+ + ${hint_text} +

+ % endfor +

+ Don't like any of the hints above? You can also submit your own. +

+ % endif +

+ What hint would you give a student who made the same mistake you did? Please don't give away the answer. +

+ +

+ +
+ % endfor +
+ +

Read about what makes a good hint.

+ + + + +<%def name="show_votes()"> + % if hint_and_votes is UNDEFINED: + Sorry, but you've already voted! + % else: + Thank you for voting! +
+ % for hint, votes in hint_and_votes: + ${votes} votes. + ${hint} +
+ % endfor + % endif + + +<%def name="simple_message()"> + ${message} + + +% if op == "get_hint": + ${get_hint()} +% endif + +% if op == "get_feedback": + ${get_feedback()} +% endif + +% if op == "submit_hint": + ${simple_message()} +% endif + +% if op == "vote": + ${show_votes()} +% endif + diff --git a/common/templates/mathjax_include.html b/common/templates/mathjax_include.html index 803f2145a4..0ddbd68eee 100644 --- a/common/templates/mathjax_include.html +++ b/common/templates/mathjax_include.html @@ -33,4 +33,4 @@ - + diff --git a/common/test/data/full/README.md b/common/test/data/full/README.md deleted file mode 100644 index 812ca471ce..0000000000 --- a/common/test/data/full/README.md +++ /dev/null @@ -1 +0,0 @@ -This is a realistic course, with many different module types and a lot of structure. It is based on 6.002x. diff --git a/common/test/data/full/about/description.html b/common/test/data/full/about/description.html deleted file mode 100644 index 305dc51750..0000000000 --- a/common/test/data/full/about/description.html +++ /dev/null @@ -1,3 +0,0 @@ -6.002x (Circuits and Electronics) is designed to serve as a first course in an undergraduate electrical engineering (EE), or electrical engineering and computer science (EECS) curriculum. At MIT, 6.002 is in the core of department subjects required for all undergraduates in EECS. - -The course introduces engineering in the context of the lumped circuit abstraction. Topics covered include: resistive elements and networks; independent and dependent sources; switches and MOS transistors; digital abstraction; amplifiers; energy storage elements; dynamics of first- and second-order networks; design in the time and frequency domains; and analog and digital circuits and applications. Design and lab exercises are also significant components of the course. You should expect to spend approximately 10 hours per week on the course. \ No newline at end of file diff --git a/common/test/data/full/about/effort.html b/common/test/data/full/about/effort.html deleted file mode 100644 index c983fdcb5c..0000000000 --- a/common/test/data/full/about/effort.html +++ /dev/null @@ -1 +0,0 @@ -12 hours \ No newline at end of file diff --git a/common/test/data/full/about/faq.html b/common/test/data/full/about/faq.html deleted file mode 100644 index a173e46753..0000000000 --- a/common/test/data/full/about/faq.html +++ /dev/null @@ -1,14 +0,0 @@ -
    -
  • What is the format of the class? -

    The course will consist of 24 lectures, each lasting 50 minutes. There will be regular assignments consisting of map tests and short essays.

    -
  • -
  • Are there any prerequisites? -

    No - anyone and everyone is welcome to take this course.

    -
  • -
  • What textbook should I buy? -

    Although the lectures are designed to be self-contained, we recommend (but do not require) that students refer to the book Worlds Together, Worlds Apart: A History of the World: From 1000 CE to the Present (W W Norton, 3rd edition) — Volume II, which was written specifically for this course.

    -
  • -
  • Does Harvard award credentials or reports regarding my work in this course? -

    Princeton does not award credentials or issue reports for student work in this course. However, Coursera could maintain a record of your score on the assessments and, with your permission, verify that score for authorized parties.

    -
  • -
diff --git a/common/test/data/full/about/more_info.html b/common/test/data/full/about/more_info.html deleted file mode 100644 index 0f5836381e..0000000000 --- a/common/test/data/full/about/more_info.html +++ /dev/null @@ -1,9 +0,0 @@ -
-

Who should take this?

-

If you're one of the many who have a unquenched interest in the worlds history, you'll love this course.

-
- -
-

Who shouldn't take this?

-

No one. Anyone and everyone is welcome to take this course.

-
\ No newline at end of file diff --git a/common/test/data/full/about/requirements.html b/common/test/data/full/about/requirements.html deleted file mode 100644 index 5890d9c036..0000000000 --- a/common/test/data/full/about/requirements.html +++ /dev/null @@ -1,2 +0,0 @@ -

In order to succeed in this course, you must have taken an AP level physics course in electricity and magnetism. You must know basic calculus and linear algebra and have some background in differential equations. Since more advanced mathematics will not show up until the second half of the course, the first half of the course will include an optional remedial differential equations component for those who need it.

-

The course web site was developed and tested primarily with Google Chrome. We support current versions of Mozilla Firefox as well. The video player is designed to work with Flash. While we provide a partial non-Flash fallback for the video, as well as partial support for Internet Explorer, other browsers, and tablets, portions of the functionality will be unavailable.

\ No newline at end of file diff --git a/common/test/data/full/about/syllabus.html b/common/test/data/full/about/syllabus.html deleted file mode 100644 index af41f420f5..0000000000 --- a/common/test/data/full/about/syllabus.html +++ /dev/null @@ -1,17 +0,0 @@ -
    -
  • Week 1: What is World History?
  • -
  • Week 2: Peoples, Plagues and Plunders
  • -
  • Week 3: Warfare and Motion
  • -
  • Week 4: Conquests
  • -
  • Week 5: The Beginnings of Globalization in the Atlantic Worlds
  • -
  • Week 6: The Beginnings of Globalization in the Indian Ocean Worlds
  • -
  • Week 7: The Worlds that Merchants Made
  • -
  • Week 8: The Seventeenth-Century Crisis
  • -
  • Week 9: Empire and Enlightenment
  • -
  • Week 10: The Wealth of Nations
  • -
  • Week 11: The World in Revolution
  • -
  • Week 12: States and Nations
  • -
  • Week 13: Global Frontiers
  • -
  • Week 14: Empires and Nations
  • -
  • Week 15: Back to the Future
  • -
diff --git a/common/test/data/full/about/textbook.html b/common/test/data/full/about/textbook.html deleted file mode 100644 index f07e7bac75..0000000000 --- a/common/test/data/full/about/textbook.html +++ /dev/null @@ -1 +0,0 @@ -

The course uses the textbook Foundations of Analog and Digital Electronic Circuits, by Anant Agarwal and Jeffrey H. Lang. Morgan Kaufmann Publishers, Elsevier, July 2005. While recommended, the book is not required: relevant sections will be provided electronically as part of the online course for personal use in connection with this course only. The copyright for the book is owned by Elsevier. The book can be purchased on Amazon.

\ No newline at end of file diff --git a/common/test/data/full/about/video.html b/common/test/data/full/about/video.html deleted file mode 100644 index 2959101024..0000000000 --- a/common/test/data/full/about/video.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/common/test/data/full/chapter/Overview.xml b/common/test/data/full/chapter/Overview.xml deleted file mode 100644 index 8ad44b366c..0000000000 --- a/common/test/data/full/chapter/Overview.xml +++ /dev/null @@ -1,10 +0,0 @@ - -