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/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 9f9a4e3e96..ea6f2fc556 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -294,6 +294,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 @@ -308,15 +329,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/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 32a213f06e..b804ae2a7a 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -90,7 +90,10 @@ MITX_FEATURES = { # Give a UI to show a student's submission history in a problem by the # Staff Debug tool. - 'ENABLE_STUDENT_HISTORY_VIEW': True + 'ENABLE_STUDENT_HISTORY_VIEW': True, + + # Provide a UI to allow users to submit feedback from the LMS + 'ENABLE_FEEDBACK_SUBMISSION': False, } # Used for A/B testing @@ -323,6 +326,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, @@ -582,3 +593,4 @@ INSTALLED_APPS = ( # Discussion forums 'django_comment_client', ) + diff --git a/lms/static/images/university/epfl/epfl-cover.jpg b/lms/static/images/university/epfl/epfl-cover.jpg index 42b188c925..e585966e7b 100644 Binary files a/lms/static/images/university/epfl/epfl-cover.jpg and b/lms/static/images/university/epfl/epfl-cover.jpg differ 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/templates/university_profile/epflx.html b/lms/templates/university_profile/epflx.html index 5119a223de..8153bd62b9 100644 --- a/lms/templates/university_profile/epflx.html +++ b/lms/templates/university_profile/epflx.html @@ -18,10 +18,13 @@ <%block name="university_description"> -

EPFL is one of the two Swiss Federal Institutes of Technology. With the status of a national school since 1969, the young engineering school has grown in many dimensions, to the extent of becoming one of the most famous European institutions of science and technology. It has three core missions: training, research and technology transfer.

-

EPFL is located in Lausanne in Switzerland, on the shores of the largest lake in Europe, Lake Geneva and at the foot of the Alps and Mont-Blanc. Its main campus brings together over 11,000 persons, students, researchers and staff in the same magical place. Because of its dynamism and rich student community, EPFL has been able to create a special spirit imbued with curiosity and simplicity. Daily interactions amongst students, researchers and entrepreneurs on campus give rise to new scientific, technological and architectural projects. -

+

EPFL is the Swiss Federal Institute of Technology in Lausanne. The past decade has seen EPFL ascend to the very top of European institutions of science and technology: it is ranked #1 in Europe in the field of engineering by the Times Higher Education (based on publications and citations), Leiden Rankings, and the Academic Ranking of World Universities.

+ +

EPFL's main campus brings together 12,600 students, faculty, researchers, and staff in a high-energy, dynamic learning and research environment. It directs the Human Brain Project, an undertaking to simulate the entire human brain using supercomputers, in order to gain new insights into how it operates and to better diagnose brain disorders. The university is building Solar Impulse, a long-range solar-powered plane that aims to be the first piloted fixed-wing aircraft to circumnavigate the Earth using only solar power. EPFL was part of the Alinghi project, developing advanced racing boats that won the America's Cup multiple times. The university operates, for education and research purposes, a Tokamak nuclear fusion reactor. EPFL also houses the Musée Bolo museum and hosts several music festivals, including Balelec, that draws over 15,000 guests every year.

+ +

EPFL is a major force in entrepreneurship, with 2012 bringing in $100M in funding for ten EPFL startups. Both young spin-offs (like Typesafe and Pix4D) and companies that have long grown past the startup stage (like Logitech) actively transfer the results of EPFL's scientific innovation to industry.

+ ${parent.body()} diff --git a/lms/urls.py b/lms/urls.py index 126d68c73e..b00813a40d 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