diff --git a/README b/README deleted file mode 100644 index 2ed50ba063..0000000000 --- a/README +++ /dev/null @@ -1 +0,0 @@ -See doc/ for documentation. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..ec17d7c9a4 --- /dev/null +++ b/README.md @@ -0,0 +1,148 @@ +This is edX, a platform for online course delivery. The project is primarily +written in [Python](http://python.org/), using the +[Django](https://www.djangoproject.com/) framework. We also use some +[Ruby](http://www.ruby-lang.org/) and some [NodeJS](http://nodejs.org/). + +Installation +============ +The installation process is a bit messy at the moment. Here's a high-level +overview of what you should do to get started. + +**TLDR:** There is a `create-dev-env.sh` script that will attempt to set all +of this up for you. If you're in a hurry, run that script. Otherwise, I suggest +that you understand what the script is doing, and why, by reading this document. + +Directory Hierarchy +------------------- +This code assumes that it is checked out in a directory that has three sibling +directories: `data` (used for XML course data), `db` (used to hold a +[sqlite](https://sqlite.org/) database), and `log` (used to hold logs). If you +clone the repository into a directory called `edx` inside of a directory +called `dev`, here's an example of how the directory hierarchy should look: + + * dev + \ + * data + * db + * log + * edx + \ + README.md + +Language Runtimes +----------------- +You'll need to be sure that you have Python 2.7, Ruby 1.9.3, and NodeJS +(latest stable) installed on your system. Some of these you can install +using your system's package manager: [homebrew](http://mxcl.github.io/homebrew/) +for Mac, [apt](http://wiki.debian.org/Apt) for Debian-based systems +(including Ubuntu), [rpm](http://www.rpm.org/) or [yum](http://yum.baseurl.org/) +for Red Hat based systems (including CentOS). + +If your system's package manager gives you the wrong version of a language +runtime, then you'll need to use a versioning tool to install the correct version. +Usually, you'll need to do this for Ruby: you can use +[`rbenv`](https://github.com/sstephenson/rbenv) or [`rvm`](https://rvm.io/), but +typically `rbenv` is simpler. For Python, you can use +[`pythonz`](http://saghul.github.io/pythonz/), +and for Node, you can use [`nvm`](https://github.com/creationix/nvm). + +Virtual Environments +-------------------- +Often, different projects will have conflicting dependencies: for example, two +projects depending on two different, incompatible versions of a library. Clearly, +you can't have both versions installed and used on your machine simultaneously. +Virtual environments were created to solve this problem: by installing libraries +into an isolated environment, only projects that live inside the environment +will be able to see and use those libraries. Got incompatible dependencies? Use +different virtual environments, and your problem is solved. + +Remember, each language has a different implementation. Python has +[`virtualenv`](http://www.virtualenv.org/), Ruby has +[`bundler`](http://gembundler.com/), and Node's virtual environment support +is built into [`npm`](https://npmjs.org/), its library management tool. +For each language, decide if you want to use a virtual environment, or if you +want to install all the language dependencies globally (and risk conflicts). +I suggest you start with installing things globally until and unless things +break; you can always switch over to a virtual environment later on. + +Language Packages +----------------- +The Python libraries we use are listed in `requirements.txt`. The Ruby libraries +we use are listed in `Gemfile`. The Node libraries we use are listed in +`packages.json`. Python has a library installer called +[`pip`](http://www.pip-installer.org/), Ruby has a library installer called +[`gem`](https://rubygems.org/) (or `bundle` if you're using a virtual +environment), and Node has a library installer called +[`npm`](https://npmjs.org/). +Once you've got your languages and virtual environments set up, install +the libraries like so: + + $ pip install -r pre-requirements.txt + $ pip install -r requirements.txt + $ bundle install + $ npm install + +Other Dependencies +------------------ +You'll also need to install [MongoDB](http://www.mongodb.org/), since our +application uses it in addition to sqlite. You can install it through your +system package manager, and I suggest that you configure it to start +automatically when you boot up your system, so that you never have to worry +about it again. For Mac, use +[`launchd`](https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man8/launchd.8.html) +(running `brew info mongodb` will give you some commands you can copy-paste.) +For Linux, you can use [`upstart`](http://upstart.ubuntu.com/), `chkconfig`, +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: + + $ rake django-admin[syncdb] + $ rake django-admin[migrate] + $ rake django-admin[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_(programming)), 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]"`. + +Run Your Project +---------------- +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. + +To run Studio, run: + + $ rake cms + +To run the LMS, run: + + $ rake lms[cms.dev] + +Studio runs on port 8001, while LMS runs on port 8000, so you can run both of +these commands simultaneously, using two different terminal windows. To view +Studio, visit `127.0.0.1:8001` in your web browser; to view the LMS, visit +`127.0.0.1:8000`. + +There's also an older version of the LMS that saves its information in XML files +in the `data` directory, instead of in Mongo. To run this older version, run: + +$ rake lms + +Further Documentation +===================== +Once you've got your project up and running, you can check out the `docs` +directory to see more documentation about how edX is structured. + + + diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 0d9621fc01..9560025441 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -2,7 +2,7 @@ from student.models import (User, UserProfile, Registration, CourseEnrollmentAllowed, CourseEnrollment) from django.contrib.auth.models import Group from datetime import datetime -from factory import DjangoModelFactory, Factory, SubFactory, PostGenerationMethodCall +from factory import DjangoModelFactory, Factory, SubFactory, PostGenerationMethodCall, post_generation from uuid import uuid4 @@ -45,6 +45,16 @@ class UserFactory(DjangoModelFactory): last_login = datetime(2012, 1, 1) date_joined = datetime(2011, 1, 1) + @post_generation + def profile(obj, create, extracted, **kwargs): + if create: + obj.save() + return UserProfileFactory.create(user=obj, **kwargs) + elif kwargs: + raise Exception("Cannot build a user profile without saving the user") + else: + return None + class AdminFactory(UserFactory): is_staff = True diff --git a/common/djangoapps/util/tests.py b/common/djangoapps/util/tests.py index 501deb776c..d829676eaf 100644 --- a/common/djangoapps/util/tests.py +++ b/common/djangoapps/util/tests.py @@ -1,16 +1,280 @@ -""" -This file demonstrates writing tests using the unittest module. These will pass -when you run "manage.py test". - -Replace this with more appropriate tests for your application. -""" +"""Tests for the util package""" +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.http import Http404 from django.test import TestCase +from django.test.client import RequestFactory +from django.test.utils import override_settings +from student.tests.factories import UserFactory +from util import views +from zendesk import ZendeskError +import json +import mock -class SimpleTest(TestCase): - def test_basic_addition(self): +@mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": True}) +@override_settings(ZENDESK_URL="dummy", ZENDESK_USER="dummy", ZENDESK_API_KEY="dummy") +@mock.patch("util.views._ZendeskApi", autospec=True) +class SubmitFeedbackViaZendeskTest(TestCase): + def setUp(self): + """Set up data for the test case""" + self._request_factory = RequestFactory() + self._anon_user = AnonymousUser() + self._auth_user = UserFactory.create( + email="test@edx.org", + username="test", + profile__name="Test User" + ) + # This contains a tag to ensure that tags are submitted correctly + self._anon_fields = { + "email": "test@edx.org", + "name": "Test User", + "subject": "a subject", + "details": "some details", + "tag": "a tag" + } + # This does not contain a tag to ensure that tag is optional + self._auth_fields = {"subject": "a subject", "details": "some details"} + + def _test_request(self, user, fields): """ - Tests that 1 + 1 always equals 2. + Generate a request and invoke the view, returning the response. + + The request will be a POST request from the given `user`, with the given + `fields` in the POST body. """ - self.assertEqual(1 + 1, 2) + req = self._request_factory.post( + "/submit_feedback", + data=fields, + HTTP_REFERER="test_referer", + HTTP_USER_AGENT="test_user_agent" + ) + req.user = user + return views.submit_feedback_via_zendesk(req) + + def _assert_bad_request(self, response, field, zendesk_mock_class): + """ + Assert that the given `response` contains correct failure data. + + It should have a 400 status code, and its content should be a JSON + object containing the specified `field` and an `error`. + """ + self.assertEqual(response.status_code, 400) + resp_json = json.loads(response.content) + self.assertTrue("field" in resp_json) + self.assertEqual(resp_json["field"], field) + self.assertTrue("error" in resp_json) + # There should be absolutely no interaction with Zendesk + self.assertFalse(zendesk_mock_class.return_value.mock_calls) + + def _test_bad_request_omit_field(self, user, fields, omit_field, zendesk_mock_class): + """ + Invoke the view with a request missing a field and assert correctness. + + The request will be a POST request from the given `user`, with POST + fields taken from `fields` minus the entry specified by `omit_field`. + The response should have a 400 (bad request) status code and specify + the invalid field and an error message, and the Zendesk API should not + have been invoked. + """ + filtered_fields = {k: v for (k, v) in fields.items() if k != omit_field} + resp = self._test_request(user, filtered_fields) + self._assert_bad_request(resp, omit_field, zendesk_mock_class) + + def _test_bad_request_empty_field(self, user, fields, empty_field, zendesk_mock_class): + """ + Invoke the view with an empty field and assert correctness. + + The request will be a POST request from the given `user`, with POST + fields taken from `fields`, replacing the entry specified by + `empty_field` with the empty string. The response should have a 400 + (bad request) status code and specify the invalid field and an error + message, and the Zendesk API should not have been invoked. + """ + altered_fields = fields.copy() + altered_fields[empty_field] = "" + resp = self._test_request(user, altered_fields) + self._assert_bad_request(resp, empty_field, zendesk_mock_class) + + def _test_success(self, user, fields): + """ + Generate a request, invoke the view, and assert success. + + The request will be a POST request from the given `user`, with the given + `fields` in the POST body. The response should have a 200 (success) + status code. + """ + resp = self._test_request(user, fields) + self.assertEqual(resp.status_code, 200) + + def test_bad_request_anon_user_no_name(self, zendesk_mock_class): + """Test a request from an anonymous user not specifying `name`.""" + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "name", zendesk_mock_class) + + def test_bad_request_anon_user_no_email(self, zendesk_mock_class): + """Test a request from an anonymous user not specifying `email`.""" + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "email", zendesk_mock_class) + + def test_bad_request_anon_user_no_subject(self, zendesk_mock_class): + """Test a request from an anonymous user not specifying `subject`.""" + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "subject", zendesk_mock_class) + + def test_bad_request_anon_user_no_details(self, zendesk_mock_class): + """Test a request from an anonymous user not specifying `details`.""" + self._test_bad_request_omit_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class) + self._test_bad_request_empty_field(self._anon_user, self._anon_fields, "details", zendesk_mock_class) + + def test_valid_request_anon_user(self, zendesk_mock_class): + """ + Test a valid request from an anonymous user. + + The response should have a 200 (success) status code, and a ticket with + the given information should have been submitted via the Zendesk API. + """ + zendesk_mock_instance = zendesk_mock_class.return_value + zendesk_mock_instance.create_ticket.return_value = 42 + self._test_success(self._anon_user, self._anon_fields) + expected_calls = [ + mock.call.create_ticket( + { + "ticket": { + "requester": {"name": "Test User", "email": "test@edx.org"}, + "subject": "a subject", + "comment": {"body": "some details"}, + "tags": ["a tag"] + } + } + ), + mock.call.update_ticket( + 42, + { + "ticket": { + "comment": { + "public": False, + "body": + "Additional information:\n\n" + "HTTP_USER_AGENT: test_user_agent\n" + "HTTP_REFERER: test_referer" + } + } + } + ) + ] + self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls) + + def test_bad_request_auth_user_no_subject(self, zendesk_mock_class): + """Test a request from an authenticated user not specifying `subject`.""" + self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class) + self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "subject", zendesk_mock_class) + + def test_bad_request_auth_user_no_details(self, zendesk_mock_class): + """Test a request from an authenticated user not specifying `details`.""" + self._test_bad_request_omit_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class) + self._test_bad_request_empty_field(self._auth_user, self._auth_fields, "details", zendesk_mock_class) + + def test_valid_request_auth_user(self, zendesk_mock_class): + """ + Test a valid request from an authenticated user. + + The response should have a 200 (success) status code, and a ticket with + the given information should have been submitted via the Zendesk API. + """ + zendesk_mock_instance = zendesk_mock_class.return_value + zendesk_mock_instance.create_ticket.return_value = 42 + self._test_success(self._auth_user, self._auth_fields) + expected_calls = [ + mock.call.create_ticket( + { + "ticket": { + "requester": {"name": "Test User", "email": "test@edx.org"}, + "subject": "a subject", + "comment": {"body": "some details"}, + "tags": [] + } + } + ), + mock.call.update_ticket( + 42, + { + "ticket": { + "comment": { + "public": False, + "body": + "Additional information:\n\n" + "username: test\n" + "HTTP_USER_AGENT: test_user_agent\n" + "HTTP_REFERER: test_referer" + } + } + } + ) + ] + self.assertEqual(zendesk_mock_instance.mock_calls, expected_calls) + + def test_get_request(self, zendesk_mock_class): + """Test that a GET results in a 405 even with all required fields""" + req = self._request_factory.get("/submit_feedback", data=self._anon_fields) + req.user = self._anon_user + resp = views.submit_feedback_via_zendesk(req) + self.assertEqual(resp.status_code, 405) + self.assertIn("Allow", resp) + self.assertEqual(resp["Allow"], "POST") + # There should be absolutely no interaction with Zendesk + self.assertFalse(zendesk_mock_class.mock_calls) + + def test_zendesk_error_on_create(self, zendesk_mock_class): + """ + Test Zendesk returning an error on ticket creation. + + We should return a 500 error with no body + """ + err = ZendeskError(msg="", error_code=404) + zendesk_mock_instance = zendesk_mock_class.return_value + zendesk_mock_instance.create_ticket.side_effect = err + resp = self._test_request(self._anon_user, self._anon_fields) + self.assertEqual(resp.status_code, 500) + self.assertFalse(resp.content) + + def test_zendesk_error_on_update(self, zendesk_mock_class): + """ + Test for Zendesk returning an error on ticket update. + + If Zendesk returns any error on ticket update, we return a 200 to the + browser because the update contains additional information that is not + necessary for the user to have submitted their feedback. + """ + err = ZendeskError(msg="", error_code=500) + zendesk_mock_instance = zendesk_mock_class.return_value + zendesk_mock_instance.update_ticket.side_effect = err + resp = self._test_request(self._anon_user, self._anon_fields) + self.assertEqual(resp.status_code, 200) + + @mock.patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_FEEDBACK_SUBMISSION": False}) + def test_not_enabled(self, zendesk_mock_class): + """ + Test for Zendesk submission not enabled in `settings`. + + We should raise Http404. + """ + with self.assertRaises(Http404): + self._test_request(self._anon_user, self._anon_fields) + + def test_zendesk_not_configured(self, zendesk_mock_class): + """ + Test for Zendesk not fully configured in `settings`. + + For each required configuration parameter, test that setting it to + `None` causes an otherwise valid request to return a 500 error. + """ + def test_case(missing_config): + with mock.patch(missing_config, None): + with self.assertRaises(Exception): + self._test_request(self._anon_user, self._anon_fields) + + test_case("django.conf.settings.ZENDESK_URL") + test_case("django.conf.settings.ZENDESK_USER") + test_case("django.conf.settings.ZENDESK_API_KEY") diff --git a/common/djangoapps/util/views.py b/common/djangoapps/util/views.py index cece37757b..c087e99cb5 100644 --- a/common/djangoapps/util/views.py +++ b/common/djangoapps/util/views.py @@ -1,5 +1,6 @@ import datetime import json +import logging import pprint import sys @@ -7,15 +8,21 @@ from django.conf import settings from django.contrib.auth.models import User from django.core.context_processors import csrf from django.core.mail import send_mail -from django.http import Http404 -from django.http import HttpResponse +from django.core.validators import ValidationError, validate_email +from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotAllowed, HttpResponseServerError from django.shortcuts import redirect +from django_future.csrf import ensure_csrf_cookie from mitxmako.shortcuts import render_to_response, render_to_string +from urllib import urlencode +import zendesk import capa.calc import track.views +log = logging.getLogger(__name__) + + def calculate(request): ''' Calculator in footer of every page. ''' equation = request.GET['equation'] @@ -29,36 +36,142 @@ def calculate(request): return HttpResponse(json.dumps({'result': str(result)})) -def send_feedback(request): - ''' Feeback mechanism in footer of every page. ''' - try: - username = request.user.username +class _ZendeskApi(object): + def __init__(self): + """ + Instantiate the Zendesk API. + + All of `ZENDESK_URL`, `ZENDESK_USER`, and `ZENDESK_API_KEY` must be set + in `django.conf.settings`. + """ + self._zendesk_instance = zendesk.Zendesk( + settings.ZENDESK_URL, + settings.ZENDESK_USER, + settings.ZENDESK_API_KEY, + use_api_token=True, + api_version=2 + ) + + def create_ticket(self, ticket): + """ + Create the given `ticket` in Zendesk. + + The ticket should have the format specified by the zendesk package. + """ + ticket_url = self._zendesk_instance.create_ticket(data=ticket) + return zendesk.get_id_from_url(ticket_url) + + def update_ticket(self, ticket_id, update): + """ + Update the Zendesk ticket with id `ticket_id` using the given `update`. + + The update should have the format specified by the zendesk package. + """ + self._zendesk_instance.update_ticket(ticket_id=ticket_id, data=update) + + +def submit_feedback_via_zendesk(request): + """ + Create a new user-requested Zendesk ticket. + + If Zendesk submission is not enabled, any request will raise `Http404`. + If any configuration parameter (`ZENDESK_URL`, `ZENDESK_USER`, or + `ZENDESK_API_KEY`) is missing, any request will raise an `Exception`. + The request must be a POST request specifying `subject` and `details`. + If the user is not authenticated, the request must also specify `name` and + `email`. If the user is authenticated, the `name` and `email` will be + populated from the user's information. If any required parameter is + missing, a 400 error will be returned indicating which field is missing and + providing an error message. If Zendesk returns any error on ticket + creation, a 500 error will be returned with no body. Once created, the + ticket will be updated with a private comment containing additional + information from the browser and server, such as HTTP headers and user + state. Whether or not the update succeeds, if the user's ticket is + successfully created, an empty successful response (200) will be returned. + """ + if not settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False): + raise Http404() + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + if ( + not settings.ZENDESK_URL or + not settings.ZENDESK_USER or + not settings.ZENDESK_API_KEY + ): + raise Exception("Zendesk enabled but not configured") + + def build_error_response(status_code, field, err_msg): + return HttpResponse(json.dumps({"field": field, "error": err_msg}), status=status_code) + + additional_info = {} + + required_fields = ["subject", "details"] + if not request.user.is_authenticated(): + required_fields += ["name", "email"] + required_field_errs = { + "subject": "Please provide a subject.", + "details": "Please provide details.", + "name": "Please provide your name.", + "email": "Please provide a valid e-mail.", + } + + for field in required_fields: + if field not in request.POST or not request.POST[field]: + return build_error_response(400, field, required_field_errs[field]) + + subject = request.POST["subject"] + details = request.POST["details"] + tags = [] + if "tag" in request.POST: + tags = [request.POST["tag"]] + + if request.user.is_authenticated(): + realname = request.user.profile.name email = request.user.email - except: - username = "anonymous" - email = "anonymous" + additional_info["username"] = request.user.username + else: + realname = request.POST["name"] + email = request.POST["email"] + try: + validate_email(email) + except ValidationError: + return build_error_response(400, "email", required_field_errs["email"]) + for header in ["HTTP_REFERER", "HTTP_USER_AGENT"]: + additional_info[header] = request.META.get(header) + + zendesk_api = _ZendeskApi() + + additional_info_string = ( + "Additional information:\n\n" + + "\n".join("%s: %s" % (key, value) for (key, value) in additional_info.items() if value is not None) + ) + + new_ticket = { + "ticket": { + "requester": {"name": realname, "email": email}, + "subject": subject, + "comment": {"body": details}, + "tags": tags + } + } try: - browser = request.META['HTTP_USER_AGENT'] - except: - browser = "Unknown" + ticket_id = zendesk_api.create_ticket(new_ticket) + except zendesk.ZendeskError as err: + log.error("%s", str(err)) + return HttpResponse(status=500) - feedback = render_to_string("feedback_email.txt", - {"subject": request.POST['subject'], - "url": request.POST['url'], - "time": datetime.datetime.now().isoformat(), - "feedback": request.POST['message'], - "email": email, - "browser": browser, - "user": username}) + # Additional information is provided as a private update so the information + # is not visible to the user. + ticket_update = {"ticket": {"comment": {"public": False, "body": additional_info_string}}} + try: + zendesk_api.update_ticket(ticket_id, ticket_update) + except zendesk.ZendeskError as err: + log.error("%s", str(err)) + # The update is not strictly necessary, so do not indicate failure to the user + pass - send_mail("MITx Feedback / " + request.POST['subject'], - feedback, - settings.DEFAULT_FROM_EMAIL, - [settings.DEFAULT_FEEDBACK_EMAIL], - fail_silently=False - ) - return HttpResponse(json.dumps({'success': True})) + return HttpResponse() def info(request): diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 1ea51bd7f1..5efd7b4005 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -162,8 +162,7 @@ class CourseFields(object): discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings) discussion_topics = Object( help="Map of topics names to ids", - scope=Scope.settings, - computed_default=lambda c: {'General': {'id': c.location.html_id()}}, + scope=Scope.settings ) testcenter_info = Object(help="Dictionary of Test Center info", scope=Scope.settings) announcement = Date(help="Date this course is announced", scope=Scope.settings) @@ -234,6 +233,8 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): self._grading_policy = {} self.set_grading_policy(self.grading_policy) + if self.discussion_topics == {}: + self.discussion_topics = {'General': {'id': self.location.html_id()}} self.test_center_exams = [] test_center_info = self.testcenter_info diff --git a/common/lib/xmodule/xmodule/js/src/.gitignore b/common/lib/xmodule/xmodule/js/src/.gitignore index bbd93c90e3..456e71bf8b 100644 --- a/common/lib/xmodule/xmodule/js/src/.gitignore +++ b/common/lib/xmodule/xmodule/js/src/.gitignore @@ -1 +1,4 @@ -# Please do not ignore *.js files. Some xmodules are written in JS. +# Ignore .js files in this folder as they are compiled from coffeescript +# For each of the xmodules subdirectories, add a .gitignore file that +# will version any *.js file that is specifically written, not compiled. +*.js diff --git a/common/lib/xmodule/xmodule/js/src/capa/.gitignore b/common/lib/xmodule/xmodule/js/src/capa/.gitignore new file mode 100644 index 0000000000..13b8deb002 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/capa/.gitignore @@ -0,0 +1,2 @@ +!imageinput.js +!schematic.js diff --git a/common/lib/xmodule/xmodule/js/src/graphical_slider_tool/.gitignore b/common/lib/xmodule/xmodule/js/src/graphical_slider_tool/.gitignore new file mode 100644 index 0000000000..d4aa116a26 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/graphical_slider_tool/.gitignore @@ -0,0 +1 @@ +!*.js diff --git a/common/lib/xmodule/xmodule/js/src/poll/.gitignore b/common/lib/xmodule/xmodule/js/src/poll/.gitignore new file mode 100644 index 0000000000..d4aa116a26 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/poll/.gitignore @@ -0,0 +1 @@ +!*.js diff --git a/common/lib/xmodule/xmodule/js/src/sequence/display/.gitignore b/common/lib/xmodule/xmodule/js/src/sequence/display/.gitignore new file mode 100644 index 0000000000..d4aa116a26 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/sequence/display/.gitignore @@ -0,0 +1 @@ +!*.js diff --git a/common/lib/xmodule/xmodule/js/src/videoalpha/display/.gitignore b/common/lib/xmodule/xmodule/js/src/videoalpha/display/.gitignore new file mode 100644 index 0000000000..c7a88ce092 --- /dev/null +++ b/common/lib/xmodule/xmodule/js/src/videoalpha/display/.gitignore @@ -0,0 +1 @@ +!html5_video.js diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index 7788e23980..31237af7b9 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -37,11 +37,17 @@ class XModuleCourseFactory(Factory): new_course.display_name = display_name new_course.lms.start = gmtime() - new_course.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, - {"type": "discussion", "name": "Discussion"}, - {"type": "wiki", "name": "Wiki"}, - {"type": "progress", "name": "Progress"}] + new_course.tabs = kwargs.get( + 'tabs', + [ + {"type": "courseware"}, + {"type": "course_info", "name": "Course Info"}, + {"type": "discussion", "name": "Discussion"}, + {"type": "wiki", "name": "Wiki"}, + {"type": "progress", "name": "Progress"} + ] + ) + new_course.discussion_link = kwargs.get('discussion_link') # Update the data in the mongo datastore store.update_metadata(new_course.location.url(), own_metadata(new_course)) diff --git a/common/lib/xmodule/xmodule/tests/test_course_module.py b/common/lib/xmodule/xmodule/tests/test_course_module.py index 15bab32c14..0d789964e9 100644 --- a/common/lib/xmodule/xmodule/tests/test_course_module.py +++ b/common/lib/xmodule/xmodule/tests/test_course_module.py @@ -40,34 +40,20 @@ class DummySystem(ImportSystem): ) -class IsNewCourseTestCase(unittest.TestCase): - """Make sure the property is_new works on courses""" +def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None, end=None): + """Get a dummy course""" - def setUp(self): - # Needed for test_is_newish - datetime_patcher = patch.object( - xmodule.course_module, 'datetime', - Mock(wraps=datetime.datetime) - ) - mocked_datetime = datetime_patcher.start() - mocked_datetime.utcnow.return_value = time_to_datetime(NOW) - self.addCleanup(datetime_patcher.stop) + system = DummySystem(load_error_modules=True) - @staticmethod - def get_dummy_course(start, announcement=None, is_new=None, advertised_start=None, end=None): - """Get a dummy course""" + def to_attrb(n, v): + return '' if v is None else '{0}="{1}"'.format(n, v).lower() - system = DummySystem(load_error_modules=True) + is_new = to_attrb('is_new', is_new) + announcement = to_attrb('announcement', announcement) + advertised_start = to_attrb('advertised_start', advertised_start) + end = to_attrb('end', end) - def to_attrb(n, v): - return '' if v is None else '{0}="{1}"'.format(n, v).lower() - - is_new = to_attrb('is_new', is_new) - announcement = to_attrb('announcement', announcement) - advertised_start = to_attrb('advertised_start', advertised_start) - end = to_attrb('end', end) - - start_xml = ''' + start_xml = ''' '''.format(org=ORG, course=COURSE, start=start, is_new=is_new, - announcement=announcement, advertised_start=advertised_start, end=end) + announcement=announcement, advertised_start=advertised_start, end=end) - return system.process_xml(start_xml) + return system.process_xml(start_xml) + + +class IsNewCourseTestCase(unittest.TestCase): + """Make sure the property is_new works on courses""" + + def setUp(self): + # Needed for test_is_newish + datetime_patcher = patch.object( + xmodule.course_module, 'datetime', + Mock(wraps=datetime.datetime) + ) + mocked_datetime = datetime_patcher.start() + mocked_datetime.utcnow.return_value = time_to_datetime(NOW) + self.addCleanup(datetime_patcher.stop) @patch('xmodule.course_module.time.gmtime') def test_sorting_score(self, gmtime_mock): @@ -120,8 +120,8 @@ class IsNewCourseTestCase(unittest.TestCase): ] for a, b, assertion in dates: - a_score = self.get_dummy_course(start=a[0], announcement=a[1], advertised_start=a[2]).sorting_score - b_score = self.get_dummy_course(start=b[0], announcement=b[1], advertised_start=b[2]).sorting_score + a_score = get_dummy_course(start=a[0], announcement=a[1], advertised_start=a[2]).sorting_score + b_score = get_dummy_course(start=b[0], announcement=b[1], advertised_start=b[2]).sorting_score print "Comparing %s to %s" % (a, b) assertion(a_score, b_score) @@ -138,36 +138,42 @@ class IsNewCourseTestCase(unittest.TestCase): ] for s in settings: - d = self.get_dummy_course(start=s[0], advertised_start=s[1]) + d = get_dummy_course(start=s[0], advertised_start=s[1]) print "Checking start=%s advertised=%s" % (s[0], s[1]) self.assertEqual(d.start_date_text, s[2]) def test_is_newish(self): - descriptor = self.get_dummy_course(start='2012-12-02T12:00', is_new=True) + descriptor = get_dummy_course(start='2012-12-02T12:00', is_new=True) assert(descriptor.is_newish is True) - descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=False) + descriptor = get_dummy_course(start='2013-02-02T12:00', is_new=False) assert(descriptor.is_newish is False) - descriptor = self.get_dummy_course(start='2013-02-02T12:00', is_new=True) + descriptor = get_dummy_course(start='2013-02-02T12:00', is_new=True) assert(descriptor.is_newish is True) - descriptor = self.get_dummy_course(start='2013-01-15T12:00') + descriptor = get_dummy_course(start='2013-01-15T12:00') assert(descriptor.is_newish is True) - descriptor = self.get_dummy_course(start='2013-03-01T12:00') + descriptor = get_dummy_course(start='2013-03-01T12:00') assert(descriptor.is_newish is True) - descriptor = self.get_dummy_course(start='2012-10-15T12:00') + descriptor = get_dummy_course(start='2012-10-15T12:00') assert(descriptor.is_newish is False) - descriptor = self.get_dummy_course(start='2012-12-31T12:00') + descriptor = get_dummy_course(start='2012-12-31T12:00') assert(descriptor.is_newish is True) def test_end_date_text(self): # No end date set, returns empty string. - d = self.get_dummy_course('2012-12-02T12:00') + d = get_dummy_course('2012-12-02T12:00') self.assertEqual('', d.end_date_text) - d = self.get_dummy_course('2012-12-02T12:00', end='2014-9-04T12:00') + d = get_dummy_course('2012-12-02T12:00', end='2014-9-04T12:00') self.assertEqual('Sep 04, 2014', d.end_date_text) + + +class DiscussionTopicsTestCase(unittest.TestCase): + def test_default_discussion_topics(self): + d = get_dummy_course('2012-12-02T12:00') + self.assertEqual({'General': {'id': 'i4x-test_org-test_course-course-test'}}, d.discussion_topics) diff --git a/doc/development.md b/doc/development.md index 95cc32329c..a6a1de4ef7 100644 --- a/doc/development.md +++ b/doc/development.md @@ -31,6 +31,14 @@ Check out the course data directories that you want to work with into the rake resetdb +## Installing + +To create your development environment, run the shell script in the root of +the repo: + + create-dev-env.sh + + ## Starting development servers Both the LMS and Studio can be started using the following shortcut tasks diff --git a/github-requirements.txt b/github-requirements.txt index 3b71d228e7..048f3cee68 100644 --- a/github-requirements.txt +++ b/github-requirements.txt @@ -5,6 +5,7 @@ -e git://github.com/edx/django-pipeline.git#egg=django-pipeline -e git://github.com/edx/django-wiki.git@e2e84558#egg=django-wiki -e git://github.com/dementrock/pystache_custom.git@776973740bdaad83a3b029f96e415a7d1e8bec2f#egg=pystache_custom-dev +-e git://github.com/eventbrite/zendesk.git@d53fe0e81b623f084e91776bcf6369f8b7b63879#egg=zendesk # Our libraries: -e git+https://github.com/edx/XBlock.git@5ce6f70a#egg=XBlock diff --git a/install.txt b/install.txt deleted file mode 100644 index 801036af6b..0000000000 --- a/install.txt +++ /dev/null @@ -1,74 +0,0 @@ -This document describes how to set up the MITx development environment -for both Linux (Ubuntu) and MacOS (OSX Lion). - -There is also a script "create-dev-env.sh" that automates these steps. - -1) Make an mitx_all directory and clone the repos - (download and install git and mercurial if you don't have them already) - - mkdir ~/mitx_all - cd ~/mitx_all - git clone git@github.com:MITx/mitx.git - hg clone ssh://hg-content@gp.mitx.mit.edu/data - -2) Install OSX dependencies (Mac users only) - - a) Install the brew utility if necessary - /usr/bin/ruby -e "$(curl -fsSL https://raw.github.com/mxcl/homebrew/master/Library/Contributions/install_homebrew.rb)" - - b) Install the brew package list - cat ~/mitx_all/mitx/brew-formulas.txt | xargs brew install - - c) Install python pip if necessary - sudo easy_install pip - - d) Install python virtualenv if necessary - sudo pip install virtualenv virtualenvwrapper - - e) Install coffee script - curl http://npmjs.org/install.sh | sh - npm install -g coffee-script - -3) Install Ubuntu dependencies (Linux users only) - - sudo apt-get install curl python-virtualenv build-essential python-dev gfortran liblapack-dev libfreetype6-dev libpng12-dev libxml2-dev libxslt-dev yui-compressor coffeescript - - -4) Install rvm, ruby, and libraries - - echo "export rvm_path=$HOME/mitx_all/ruby" > $HOME/.rvmrc - curl -sL get.rvm.io | bash -s stable - source ~/mitx_all/ruby/scripts/rvm - rvm install 1.9.3 - gem install bundler - cd ~/mitx_all/mitx - bundle install - -5) Install python libraries - - source ~/mitx_all/python/bin/activate - cd ~/mitx_all - pip install -r mitx/pre-requirements.txt - pip install -r mitx/requirements.txt - -6) Create log and db dirs - - mkdir ~/mitx_all/log - mkdir ~/mitx_all/db - -7) Start the dev server - - To start using Django you will need - to activate the local Python and Ruby - environment: - - $ source ~/mitx_all/ruby/scripts/rvm - $ source ~/mitx_all/python/bin/activate - - To initialize and start a local instance of Django: - - $ cd ~/mitx_all/mitx - $ django-admin.py syncdb --settings=envs.dev --pythonpath=. - $ django-admin.py migrate --settings=envs.dev --pythonpath=. - $ django-admin.py runserver --settings=envs.dev --pythonpath=. - diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 0fc86704dd..42b1c05743 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -300,6 +300,27 @@ def get_course_tabs(user, course, active_page): return tabs +def get_discussion_link(course): + """ + Return the URL for the discussion tab for the given `course`. + + If they have a discussion link specified, use that even if we disable + discussions. Disabling discsussions is mostly a server safety feature at + this point, and we don't need to worry about external sites. Otherwise, + if the course has a discussion tab or uses the default tabs, return the + discussion view URL. Otherwise, return None to indicate the lack of a + discussion tab. + """ + if course.discussion_link: + return course.discussion_link + elif not settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'): + return None + elif hasattr(course, 'tabs') and course.tabs and not any([tab['type'] == 'discussion' for tab in course.tabs]): + return None + else: + return reverse('django_comment_client.forum.views.forum_form_discussion', args=[course.id]) + + def get_default_tabs(user, course, active_page): # When calling the various _tab methods, can omit the 'type':'blah' from the @@ -314,15 +335,9 @@ def get_default_tabs(user, course, active_page): tabs.extend(_textbooks({}, user, course, active_page)) - ## If they have a discussion link specified, use that even if we feature - ## flag discussions off. Disabling that is mostly a server safety feature - ## at this point, and we don't need to worry about external sites. - if course.discussion_link: - tabs.append(CourseTab('Discussion', course.discussion_link, active_page == 'discussion')) - elif settings.MITX_FEATURES.get('ENABLE_DISCUSSION_SERVICE'): - link = reverse('django_comment_client.forum.views.forum_form_discussion', - args=[course.id]) - tabs.append(CourseTab('Discussion', link, active_page == 'discussion')) + discussion_link = get_discussion_link(course) + if discussion_link: + tabs.append(CourseTab('Discussion', discussion_link, active_page == 'discussion')) tabs.extend(_wiki({'name': 'Wiki', 'type': 'wiki'}, user, course, active_page)) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 928b9ae0df..04c46a7820 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -1,11 +1,15 @@ from django.test import TestCase from mock import MagicMock +from mock import patch import courseware.tabs as tabs from django.test.utils import override_settings from django.core.urlresolvers import reverse +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class ProgressTestCase(TestCase): @@ -257,3 +261,62 @@ class ValidateTabsTestCase(TestCase): self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[2]) self.assertIsNone(tabs.validate_tabs(self.courses[3])) self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4]) + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class DiscussionLinkTestCase(ModuleStoreTestCase): + + def setUp(self): + self.tabs_with_discussion = [ + {'type':'courseware'}, + {'type':'course_info'}, + {'type':'discussion'}, + {'type':'textbooks'}, + ] + self.tabs_without_discussion = [ + {'type':'courseware'}, + {'type':'course_info'}, + {'type':'textbooks'}, + ] + + @staticmethod + def _patch_reverse(course): + def patched_reverse(viewname, args): + if viewname == "django_comment_client.forum.views.forum_form_discussion" and args == [course.id]: + return "default_discussion_link" + else: + return None + return patch("courseware.tabs.reverse", patched_reverse) + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": False}) + def test_explicit_discussion_link(self): + """Test that setting discussion_link overrides everything else""" + course = CourseFactory.create(discussion_link="other_discussion_link", tabs=self.tabs_with_discussion) + self.assertEqual(tabs.get_discussion_link(course), "other_discussion_link") + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": False}) + def test_discussions_disabled(self): + """Test that other cases return None with discussions disabled""" + for i, t in enumerate([None, self.tabs_with_discussion, self.tabs_without_discussion]): + course = CourseFactory.create(tabs=t, number=str(i)) + self.assertEqual(tabs.get_discussion_link(course), None) + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def test_no_tabs(self): + """Test a course without tabs configured""" + course = CourseFactory.create(tabs=None) + with self._patch_reverse(course): + self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link") + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def test_tabs_with_discussion(self): + """Test a course with a discussion tab configured""" + course = CourseFactory.create(tabs=self.tabs_with_discussion) + with self._patch_reverse(course): + self.assertEqual(tabs.get_discussion_link(course), "default_discussion_link") + + @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def test_tabs_without_discussion(self): + """Test a course with tabs configured but without a discussion tab""" + course = CourseFactory.create(tabs=self.tabs_without_discussion) + self.assertEqual(tabs.get_discussion_link(course), None) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 4c9f592797..d5064ec5e5 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,13 +1,13 @@ ''' Test for lms courseware app ''' - import logging import json import time import random from urlparse import urlsplit, urlunsplit +from uuid import uuid4 from django.contrib.auth.models import User, Group from django.test import TestCase @@ -62,7 +62,7 @@ def mongo_store_config(data_dir): 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore', + 'collection': 'modulestore_%s' % uuid4().hex, 'fs_root': data_dir, 'render_template': 'mitxmako.shortcuts.render_to_string', } @@ -81,7 +81,7 @@ def draft_mongo_store_config(data_dir): 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore', + 'collection': 'modulestore_%s' % uuid4().hex, 'fs_root': data_dir, 'render_template': 'mitxmako.shortcuts.render_to_string', } @@ -92,7 +92,7 @@ def draft_mongo_store_config(data_dir): 'default_class': 'xmodule.raw_module.RawDescriptor', 'host': 'localhost', 'db': 'test_xmodule', - 'collection': 'modulestore', + 'collection': 'modulestore_%s' % uuid4().hex, 'fs_root': data_dir, 'render_template': 'mitxmako.shortcuts.render_to_string', } diff --git a/lms/djangoapps/instructor/tests/test_gradebook.py b/lms/djangoapps/instructor/tests/test_gradebook.py index 2de5c18bcd..4b1d22b594 100644 --- a/lms/djangoapps/instructor/tests/test_gradebook.py +++ b/lms/djangoapps/instructor/tests/test_gradebook.py @@ -49,7 +49,6 @@ class TestGradebook(ModuleStoreTestCase): ] for user in self.users: - UserProfileFactory.create(user=user) CourseEnrollmentFactory.create(user=user, course_id=self.course.id) for i in xrange(USER_COUNT-1): @@ -151,4 +150,4 @@ class TestLetterCutoffPolicy(TestGradebook): # User 0 has 0 on Homeworks [1] # User 0 has 0 on the class [1] # One use at the top of the page [1] - self.assertEquals(3, self.response.content.count('grade_None')) \ No newline at end of file + self.assertEquals(3, self.response.content.count('grade_None')) diff --git a/lms/envs/aws.py b/lms/envs/aws.py index df81c1e3a9..fade92f612 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -88,6 +88,8 @@ META_UNIVERSITIES = ENV_TOKENS.get('META_UNIVERSITIES', {}) COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') +ZENDESK_URL = ENV_TOKENS.get("ZENDESK_URL") +FEEDBACK_SUBMISSION_EMAIL = ENV_TOKENS.get("FEEDBACK_SUBMISSION_EMAIL") ############################## SECURE AUTH ITEMS ############### # Secret things: passwords, access keys, etc. @@ -123,3 +125,6 @@ DATADOG_API = AUTH_TOKENS.get("DATADOG_API") # Analytics dashboard server ANALYTICS_SERVER_URL = ENV_TOKENS.get("ANALYTICS_SERVER_URL") ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", "") + +ZENDESK_USER = AUTH_TOKENS.get("ZENDESK_USER") +ZENDESK_API_KEY = AUTH_TOKENS.get("ZENDESK_API_KEY") diff --git a/lms/envs/common.py b/lms/envs/common.py index 876cff6a2e..f436cace40 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -93,7 +93,10 @@ MITX_FEATURES = { 'ENABLE_STUDENT_HISTORY_VIEW': True, # Enables the student notes API and UI. - 'ENABLE_STUDENT_NOTES': True + 'ENABLE_STUDENT_NOTES': True, + + # Provide a UI to allow users to submit feedback from the LMS + 'ENABLE_FEEDBACK_SUBMISSION': False, } # Used for A/B testing @@ -326,6 +329,14 @@ WIKI_LINK_DEFAULT_LEVEL = 2 PEARSONVUE_SIGNINPAGE_URL = "https://www1.pearsonvue.com/testtaker/signin/SignInPage/EDX" # TESTCENTER_ACCOMMODATION_REQUEST_EMAIL = "exam-help@edx.org" +##### Feedback submission mechanism ##### +FEEDBACK_SUBMISSION_EMAIL = None + +##### Zendesk ##### +ZENDESK_URL = None +ZENDESK_USER = None +ZENDESK_API_KEY = None + ################################# open ended grading config ##################### #By setting up the default settings with an incorrect user name and password, @@ -598,3 +609,4 @@ INSTALLED_APPS = ( # Student notes 'notes', ) + diff --git a/lms/static/sass/base/_base.scss b/lms/static/sass/base/_base.scss index ca56f542d6..d2d4a0564f 100644 --- a/lms/static/sass/base/_base.scss +++ b/lms/static/sass/base/_base.scss @@ -202,5 +202,62 @@ mark { } } +.help-tab { + @include transform(rotate(-90deg)); + @include transform-origin(0 0); + top: 50%; + left: 0; + position: fixed; + z-index: 99; + a:link, a:visited { + cursor: pointer; + border: 1px solid #ccc; + border-top-style: none; + @include border-radius(0px 0px 10px 10px); + background: transparentize(#fff, 0.25); + color: transparentize(#333, 0.25); + font-weight: bold; + text-decoration: none; + padding: 6px 22px 11px; + display: inline-block; + &:hover { + color: #fff; + background: #1D9DD9; + } + } +} + +.help-buttons { + padding: 10px 50px; + + a:link, a:visited { + padding: 15px 0px; + text-align: center; + cursor: pointer; + background: #fff; + text-decoration: none; + display: block; + border: 1px solid #ccc; + + &#feedback_link_problem { + border-bottom-style: none; + @include border-radius(10px 10px 0px 0px); + } + + &#feedback_link_question { + border-top-style: none; + @include border-radius(0px 0px 10px 10px); + } + + &:hover { + color: #fff; + background: #1D9DD9; + } + } +} + +#feedback_form textarea[name="details"] { + height: 150px; +} diff --git a/lms/static/sass/shared/_modal.scss b/lms/static/sass/shared/_modal.scss index bfa803fee2..2da64d54a6 100644 --- a/lms/static/sass/shared/_modal.scss +++ b/lms/static/sass/shared/_modal.scss @@ -155,7 +155,7 @@ display: block; color: #8F0E0E; - + input { + + input, + textarea { border: 1px solid #CA1111; color: #8F0E0E; } diff --git a/lms/templates/help_modal.html b/lms/templates/help_modal.html new file mode 100644 index 0000000000..83ea00068f --- /dev/null +++ b/lms/templates/help_modal.html @@ -0,0 +1,167 @@ +<%namespace name='static' file='static_content.html'/> +<%! from django.conf import settings %> +<%! from courseware.tabs import get_discussion_link %> + +% if settings.MITX_FEATURES.get('ENABLE_FEEDBACK_SUBMISSION', False): + +
+ Help +
+ + + + + +%endif diff --git a/lms/templates/navigation.html b/lms/templates/navigation.html index e4c23e4836..4bb99d1ebd 100644 --- a/lms/templates/navigation.html +++ b/lms/templates/navigation.html @@ -96,3 +96,5 @@ site_status_msg = get_site_status_msg(course_id) <%include file="signup_modal.html" /> <%include file="forgot_password_modal.html" /> %endif + +<%include file="help_modal.html"/> diff --git a/lms/urls.py b/lms/urls.py index 01bb55b471..27c611204f 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -114,8 +114,9 @@ urlpatterns = ('', # nopep8 # Favicon (r'^favicon\.ico$', 'django.views.generic.simple.redirect_to', {'url': '/static/images/favicon.ico'}), + url(r'^submit_feedback$', 'util.views.submit_feedback_via_zendesk'), + # TODO: These urls no longer work. They need to be updated before they are re-enabled - # url(r'^send_feedback$', 'util.views.send_feedback'), # url(r'^reactivate/(?P[^/]*)$', 'student.views.reactivation_email'), ) diff --git a/pre-requirements.txt b/pre-requirements.txt index 7ecead0ce7..d39199a741 100644 --- a/pre-requirements.txt +++ b/pre-requirements.txt @@ -1,2 +1,10 @@ +# We use `scipy` in our project, which relies on `numpy`. `pip` apparently +# installs packages in a two-step process, where it will first try to build +# all packages, and then try to install all packages. As a result, if we simply +# added these packages to the top of `requirements.txt`, `pip` would try to +# build `scipy` before `numpy` has been installed, and it would fail. By +# separating this out into a `pre-requirements.txt` file, we can make sure +# that `numpy` is built *and* installed before we try to build `scipy`. + numpy==1.6.2 distribute>=0.6.28