merged master branch into feature/abarrett/lms-notes-app
This commit is contained in:
148
README.md
Normal file
148
README.md
Normal file
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
5
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
5
common/lib/xmodule/xmodule/js/src/.gitignore
vendored
@@ -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
|
||||
|
||||
2
common/lib/xmodule/xmodule/js/src/capa/.gitignore
vendored
Normal file
2
common/lib/xmodule/xmodule/js/src/capa/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
!imageinput.js
|
||||
!schematic.js
|
||||
1
common/lib/xmodule/xmodule/js/src/graphical_slider_tool/.gitignore
vendored
Normal file
1
common/lib/xmodule/xmodule/js/src/graphical_slider_tool/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!*.js
|
||||
1
common/lib/xmodule/xmodule/js/src/poll/.gitignore
vendored
Normal file
1
common/lib/xmodule/xmodule/js/src/poll/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!*.js
|
||||
1
common/lib/xmodule/xmodule/js/src/sequence/display/.gitignore
vendored
Normal file
1
common/lib/xmodule/xmodule/js/src/sequence/display/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!*.js
|
||||
1
common/lib/xmodule/xmodule/js/src/videoalpha/display/.gitignore
vendored
Normal file
1
common/lib/xmodule/xmodule/js/src/videoalpha/display/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
!html5_video.js
|
||||
@@ -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))
|
||||
|
||||
@@ -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 = '''
|
||||
<course org="{org}" course="{course}"
|
||||
graceperiod="1 day" url_name="test"
|
||||
start="{start}"
|
||||
@@ -80,9 +66,23 @@ class IsNewCourseTestCase(unittest.TestCase):
|
||||
</chapter>
|
||||
</course>
|
||||
'''.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
74
install.txt
74
install.txt
@@ -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=.
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
self.assertEquals(3, self.response.content.count('grade_None'))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
display: block;
|
||||
color: #8F0E0E;
|
||||
|
||||
+ input {
|
||||
+ input, + textarea {
|
||||
border: 1px solid #CA1111;
|
||||
color: #8F0E0E;
|
||||
}
|
||||
|
||||
167
lms/templates/help_modal.html
Normal file
167
lms/templates/help_modal.html
Normal file
@@ -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):
|
||||
|
||||
<div class="help-tab">
|
||||
<a href="#help-modal" rel="leanModal">Help</a>
|
||||
</div>
|
||||
|
||||
<section id="help-modal" class="modal">
|
||||
<div class="inner-wrapper" id="help_wrapper">
|
||||
<header>
|
||||
<h2><span class="edx">edX</span> Help</h2>
|
||||
<hr>
|
||||
</header>
|
||||
|
||||
<%
|
||||
discussion_link = get_discussion_link(course) if course else None
|
||||
%>
|
||||
% if discussion_link:
|
||||
<p>
|
||||
Have a course-specific question?
|
||||
<a href="${discussion_link}" target="_blank"/>
|
||||
Post it on the course forums.
|
||||
</a>
|
||||
</p>
|
||||
<hr>
|
||||
% endif
|
||||
|
||||
<p>Have a general question about edX? <a href="/help" target="_blank">Check the FAQ</a>.</p>
|
||||
<hr>
|
||||
|
||||
<div class="help-buttons">
|
||||
<a href="#" id="feedback_link_problem">Report a problem</a>
|
||||
<a href="#" id="feedback_link_suggestion">Make a suggestion</a>
|
||||
<a href="#" id="feedback_link_question">Ask a question</a>
|
||||
</div>
|
||||
|
||||
## TODO: find a way to refactor this
|
||||
<div class="close-modal">
|
||||
<div class="inner">
|
||||
<p>✕</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inner-wrapper" id="feedback_form_wrapper">
|
||||
<header></header>
|
||||
|
||||
<form id="feedback_form" class="feedback_form" method="post" data-remote="true" action="/submit_feedback">
|
||||
<div id="feedback_error" class="modal-form-error"></div>
|
||||
% if not user.is_authenticated():
|
||||
<label data-field="name">Name*</label>
|
||||
<input name="name" type="text">
|
||||
<label data-field="email">E-mail*</label>
|
||||
<input name="email" type="text">
|
||||
% endif
|
||||
<label data-field="subject">Subject*</label>
|
||||
<input name="subject" type="text">
|
||||
<label data-field="details">Details*</label>
|
||||
<textarea name="details"></textarea>
|
||||
<input name="tag" type="hidden">
|
||||
<div class="submit">
|
||||
<input name="submit" type="submit" value="Submit">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="close-modal">
|
||||
<div class="inner">
|
||||
<p>✕</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inner-wrapper" id="feedback_success_wrapper">
|
||||
<header>
|
||||
<h2>Thank You!</h2>
|
||||
<hr>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Thanks for your feedback. We will read your message, and our
|
||||
support team may contact you to respond or ask for further clarification.
|
||||
</p>
|
||||
|
||||
<div class="close-modal">
|
||||
<div class="inner">
|
||||
<p>✕</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
$(".help-tab").click(function() {
|
||||
$(".field-error").removeClass("field-error");
|
||||
$("#feedback_form")[0].reset();
|
||||
$("#feedback_form input[type='submit']").removeAttr("disabled");
|
||||
$("#feedback_form_wrapper").css("display", "none");
|
||||
$("#feedback_error").css("display", "none");
|
||||
$("#feedback_success_wrapper").css("display", "none");
|
||||
$("#help_wrapper").css("display", "block");
|
||||
});
|
||||
showFeedback = function(e, tag, title) {
|
||||
$("#help_wrapper").css("display", "none");
|
||||
$("#feedback_form input[name='tag']").val(tag);
|
||||
$("#feedback_form_wrapper").css("display", "block");
|
||||
$("#feedback_form_wrapper header").html("<h2>" + title + "</h2><hr>");
|
||||
e.preventDefault();
|
||||
};
|
||||
$("#feedback_link_problem").click(function(e) {
|
||||
showFeedback(e, "problem", "Report a Problem");
|
||||
});
|
||||
$("#feedback_link_suggestion").click(function(e) {
|
||||
showFeedback(e, "suggestion", "Make a Suggestion");
|
||||
});
|
||||
$("#feedback_link_question").click(function(e) {
|
||||
showFeedback(e, "question", "Ask a Question");
|
||||
});
|
||||
$("#feedback_form").submit(function() {
|
||||
$("input[type='submit']", this).attr("disabled", "disabled");
|
||||
});
|
||||
$("#feedback_form").on("ajax:complete", function() {
|
||||
$("input[type='submit']", this).removeAttr("disabled");
|
||||
});
|
||||
$("#feedback_form").on("ajax:success", function(event, data, status, xhr) {
|
||||
$("#feedback_form_wrapper").css("display", "none");
|
||||
$("#feedback_success_wrapper").css("display", "block");
|
||||
});
|
||||
$("#feedback_form").on("ajax:error", function(event, xhr, status, error) {
|
||||
$(".field-error").removeClass("field-error");
|
||||
var responseData;
|
||||
try {
|
||||
responseData = jQuery.parseJSON(xhr.responseText);
|
||||
} catch(err) {
|
||||
}
|
||||
if (responseData) {
|
||||
$("[data-field='"+responseData.field+"']").addClass("field-error");
|
||||
$("#feedback_error").html(responseData.error).stop().css("display", "block");
|
||||
} else {
|
||||
// If no data (or malformed data) is returned, a server error occurred
|
||||
htmlStr = "An error has occurred.";
|
||||
% if settings.FEEDBACK_SUBMISSION_EMAIL:
|
||||
htmlStr += " Please <a href='#' id='feedback_email'>send us e-mail</a>.";
|
||||
% else:
|
||||
// If no email is configured, we can't do much other than
|
||||
// ask the user to try again later
|
||||
htmlStr += " Please try again later.";
|
||||
% endif
|
||||
$("#feedback_error").html(htmlStr).stop().css("display", "block");
|
||||
% if settings.FEEDBACK_SUBMISSION_EMAIL:
|
||||
$("#feedback_email").click(function(e) {
|
||||
mailto = "mailto:" + "${settings.FEEDBACK_SUBMISSION_EMAIL}" +
|
||||
"?subject=" + $("#feedback_form input[name='subject']").val() +
|
||||
"&body=" + $("#feedback_form textarea[name='details']").val();
|
||||
window.open(mailto);
|
||||
e.preventDefault();
|
||||
});
|
||||
%endif
|
||||
}
|
||||
});
|
||||
})(this)
|
||||
</script>
|
||||
|
||||
%endif
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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<key>[^/]*)$', 'student.views.reactivation_email'),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user