{"action":"created","comment":{"url":"https://api.github.com/repos/edx/edx-platform/pulls/comments/29290251","id":29290251,"diff_hunk":"@@ -0,0 +1,529 @@\n+\"\"\"\n+These views handle all actions in Studio related to import and exporting of\n+courses\n+\"\"\"\n+import base64\n+import logging\n+from opaque_keys import InvalidKeyError\n+import os\n+import re\n+import shutil\n+import tarfile\n+from path import path\n+from tempfile import mkdtemp\n+\n+from django.conf import settings\n+from django.core.cache import cache\n+from django.core.exceptions import SuspiciousOperation\n+from django.core.files.temp import NamedTemporaryFile\n+from django.core.servers.basehttp import FileWrapper\n+from django.http import HttpResponse, Http404\n+from django.utils.translation import ugettext as _\n+from django.shortcuts import redirect\n+\n+from rest_framework import renderers\n+from rest_framework.authentication import OAuth2Authentication, \\\n+    SessionAuthentication\n+from rest_framework.decorators import renderer_classes \\\n+    as renderer_classes_decorator\n+from rest_framework.permissions import IsAuthenticated, BasePermission\n+from rest_framework.renderers import JSONRenderer\n+from rest_framework.response import Response\n+from rest_framework.views import APIView\n+\n+from xmodule.contentstore.django import contentstore\n+from xmodule.exceptions import SerializationError\n+from xmodule.modulestore.django import modulestore\n+from opaque_keys.edx.keys import CourseKey\n+from opaque_keys.edx.locator import LibraryLocator\n+from xmodule.modulestore.xml_importer import import_course_from_xml, import_library_from_xml\n+from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml\n+from xmodule.modulestore import COURSE_ROOT, LIBRARY_ROOT\n+\n+from student.auth import has_course_author_access\n+\n+from openedx.core.lib.extract_tar import safetar_extractall\n+from util.json_request import JsonResponse\n+from util.views import ensure_valid_course_key\n+\n+from urllib import urlencode\n+\n+log = logging.getLogger(__name__)\n+\n+\n+# Regex to capture Content-Range header ranges.\n+CONTENT_RE = re.compile(\n+    r\"(?P<start>\\d{1,11})-(?P<stop>\\d{1,11})/(?P<end>\\d{1,11})\"\n+)\n+\n+\n+class IsCourseStaff(BasePermission):\n+    \"\"\"\n+    Permission that checks to see if the request user has permission to access\n+    all course content of the requested course\n+    \"\"\"\n+    def has_permission(self, request, view):\n+        course_key_string = view.kwargs['course_key_string']\n+        try:\n+            course_key = CourseKey.from_string(course_key_string)\n+        except InvalidKeyError:\n+            raise Http404\n+\n+        return has_course_author_access(request.user, course_key)\n+\n+\n+class ArchiveRenderer(renderers.BaseRenderer):\n+    \"\"\"\n+    A Renderer for compressed tars. It gets used at the content negotiation\n+    stage, but \"render\" never actually gets used.\n+    \"\"\"\n+    media_type = \"application/x-tgz\"\n+    format = None\n+    render_style = \"binary\"\n+\n+    # pylint: disable=unused-argument\n+    def render(self, data, media_type=None, render_context=None):\n+        return data\n+\n+\n+class FullCourseImportStatus(APIView):\n+    \"\"\"\n+    View the import status of a full course import.\n+    \"\"\"\n+    authentication_classes = (OAuth2Authentication, SessionAuthentication)\n+    permission_classes = (IsAuthenticated, IsCourseStaff)\n+\n+    # pylint: disable=unused-argument\n+    @ensure_valid_course_key\n+    def get(self, request, course_key_string, filename=None):\n+        \"\"\"\n+        Returns an integer corresponding to the status of a file import.\n+        These are:\n+\n+            -X : Import unsuccessful due to some error with X as stage [0-3]\n+            0 : No status info found (import done or upload still in progress)\n+            1 : Extracting file\n+            2 : Validating.\n+            3 : Importing to mongo\n+            4 : Import successful\n+\n+        \"\"\"\n+        status_key = \"{}|{}{}\".format(\n+            request.user.username,\n+            course_key_string,\n+            filename\n+        )\n+        status = cache.get(status_key, 0)\n+\n+        return Response({\"ImportStatus\": status})\n+\n+\n+class FullCourseImportExport(APIView):\n+    \"\"\"\n+    Import or export a full course archive.\n+    \"\"\"\n+    authentication_classes = (OAuth2Authentication, SessionAuthentication)\n+    permission_classes = (IsAuthenticated, IsCourseStaff)\n+    renderer_classes = (ArchiveRenderer, JSONRenderer)\n+\n+    def _save_request_status(self, request, key, status):\n+        \"\"\"\n+        Save import status for a course in request session\n+        \"\"\"\n+        cache.set(\n+            \"{}|{}\".format(request.user.username, key),\n+            status\n+        )\n+\n+    def _export_error_response(self, params, redirect_url=None):\n+        if redirect_url:\n+            return redirect(\"{0}?{1}\".format(\n+                redirect_url,\n+                urlencode(params)\n+            ))\n+        else:\n+            return JsonResponse(params)\n+\n+    # pylint: disable=unused-argument\n+    @ensure_valid_course_key\n+    @renderer_classes_decorator((ArchiveRenderer,))\n+    def get(self, request, course_key_string):\n+        \"\"\"\n+        The restful handler for exporting a full course or content library.\n+\n+        GET\n+            application/x-tgz: return tar.gz file containing exported course\n+            json: not supported\n+\n+        Note that there are 2 ways to request the tar.gz file. The request\n+        header can specify application/x-tgz via HTTP_ACCEPT, or a query\n+        parameter can be used (?accept=application/x-tgz).\n+\n+        If the tar.gz file has been requested but the export operation fails,\n+        a JSON string will be returned which describes the error\n+        \"\"\"\n+        redirect_url = request.QUERY_PARAMS.get('redirect', None)\n+\n+        courselike_key = CourseKey.from_string(course_key_string)\n+        library = isinstance(courselike_key, LibraryLocator)\n+\n+        if library:\n+            courselike_module = modulestore().get_library(courselike_key)\n+        else:\n+            courselike_module = modulestore().get_course(courselike_key)\n+\n+        name = courselike_module.url_name\n+        export_file = NamedTemporaryFile(prefix=name + '.', suffix=\".tar.gz\")\n+        root_dir = path(mkdtemp())\n+\n+        try:\n+            if library:\n+                export_library_to_xml(\n+                    modulestore(),\n+                    contentstore(),\n+                    courselike_key,\n+                    root_dir,\n+                    name\n+                )\n+            else:\n+                export_course_to_xml(\n+                    modulestore(),\n+                    contentstore(),\n+                    courselike_module.id,\n+                    root_dir,\n+                    name\n+                )\n+\n+            logging.debug(\n+                u'tar file being generated at {0}'.format(export_file.name)\n+            )\n+            with tarfile.open(name=export_file.name, mode='w:gz') as tar_file:\n+                tar_file.add(root_dir / name, arcname=name)\n+        except SerializationError as exc:\n+            log.exception(\n+                u'There was an error exporting course %s',\n+                courselike_key\n+            )\n+            unit = None\n+            failed_item = None\n+            parent = None\n+            try:\n+                failed_item = modulestore().get_item(exc.location)\n+                parent_loc = modulestore().get_parent_location(\n+                    failed_item.location\n+                )\n+\n+                if parent_loc is not None:\n+                    parent = modulestore().get_item(parent_loc)\n+                    if parent.location.category == 'vertical':\n+                        unit = parent\n+            except:  # pylint: disable=bare-except\n+                # if we have a nested exception, then we'll show the more\n+                # generic error message\n+                pass\n+\n+            return self._export_error_response(\n+                {\n+                    \"context_course\": str(courselike_module.location),\n+                    \"error\": True,\n+                    \"error_message\": str(exc),\n+                    \"failed_module\":\n+                    str(failed_item.location) if failed_item else None,\n+                    \"unit\":\n+                    str(unit.location) if unit else None\n+                },\n+                redirect_url=redirect_url\n+            )\n+        except Exception as exc:\n+            log.exception(\n+                'There was an error exporting course %s',\n+                courselike_key\n+            )\n+            return self._export_error_response(\n+                {\n+                    \"context_course\": courselike_module.url_name,\n+                    \"error\": True,\n+                    \"error_message\": str(exc),\n+                    \"unit\": None\n+                },\n+                redirect_url=redirect_url\n+            )\n+        finally:\n+            shutil.rmtree(root_dir / name)\n+\n+        # The course is all set; return the tar.gz\n+        wrapper = FileWrapper(export_file)\n+\n+        response = HttpResponse(wrapper, content_type='application/x-tgz')\n+        response['Content-Disposition'] = 'attachment; filename={}'.format(\n+            os.path.basename(\n+                export_file.name.encode('utf-8')\n+            )\n+        )\n+        response['Content-Length'] = os.path.getsize(export_file.name)\n+        return response\n+\n+    # pylint: disable=unused-argument\n+    @ensure_valid_course_key\n+    @renderer_classes_decorator((JSONRenderer,))\n+    def post(self, request, course_key_string):\n+        \"\"\"\n+        The restful handler for importing a course.\n+\n+        GET\n+            json: return json import status\n+        POST or PUT\n+            json: import a course via the .tar.gz file specified inrequest.FILES\n+        \"\"\"\n+        courselike_key = CourseKey.from_string(course_key_string)\n+        library = isinstance(courselike_key, LibraryLocator)\n+\n+        if library:\n+            root_name = LIBRARY_ROOT\n+            context_name = \"context_library\"\n+            courselike_module = modulestore().get_library(courselike_key)\n+            import_func = import_library_from_xml\n+        else:\n+            root_name = COURSE_ROOT\n+            context_name = \"context_course\"\n+            courselike_module = modulestore().get_course(courselike_key)\n+            import_func = import_course_from_xml\n+\n+        filename = request.FILES['course-data'].name\n+        courselike_string = unicode(courselike_key) + filename\n+        data_root = path(settings.GITHUB_REPO_ROOT)\n+        subdir = base64.urlsafe_b64encode(repr(courselike_key))\n+        course_dir = data_root / subdir\n+\n+        status_key = \"{}|{}\".format(\n+            request.user.username,\n+            courselike_string\n+        )\n+\n+        # Do everything in a try-except block to make sure everything is\n+        # properly cleaned up.\n+        try:\n+            # Cache the import progress\n+            self._save_request_status(request, courselike_string, 0)\n+            if not filename.endswith('.tar.gz'):\n+                self._save_request_status(request, courselike_string, -1)\n+                return JsonResponse(\n+                    {\n+                        'error_message': _(\n+                            'We only support uploading a .tar.gz file.'\n+                        ),\n+                        'stage': -1\n+                    },\n+                    status=415\n+                )\n+\n+            temp_filepath = course_dir / filename\n+\n+            # Only handle exceptions caused by the directory already existing,\n+            # to avoid a potential race condition caused by the \"check and go\"\n+            # method.\n+            try:\n+                os.makedirs(course_dir)\n+            except OSError as e:\n+                if e.errno != e.EEXIST:\n+                    raise\n+\n+            logging.debug('importing course to {0}'.format(temp_filepath))\n+\n+            # Get upload chunks byte ranges\n+            try:\n+                matches = CONTENT_RE.search(request.META[\"HTTP_CONTENT_RANGE\"])\n+                content_range = matches.groupdict()\n+            except KeyError:    # Single chunk\n+                # no Content-Range header, so make one that will work\n+                content_range = {'start': 0, 'stop': 1, 'end': 2}\n+\n+            # stream out the uploaded files in chunks to disk\n+            if int(content_range['start']) == 0:\n+                mode = \"wb+\"\n+            else:\n+                mode = \"ab+\"\n+                size = os.path.getsize(temp_filepath)\n+                # Check to make sure we haven't missed a chunk\n+                # This shouldn't happen, even if different instances are\n+                # handling the same session, but it's always better to catch\n+                # errors earlier.\n+                if size < int(content_range['start']):\n+                    self._save_request_status(request, courselike_string, -1)\n+                    log.warning(\n+                        \"Reported range %s does not match size downloaded so \"\n+                        \"far %s\",\n+                        content_range['start'],\n+                        size\n+                    )\n+                    return JsonResponse(\n+                        {\n+                            'error_message': _(\n+                                'File upload corrupted. Please try again'\n+                            ),\n+                            'stage': -1\n+                        },\n+                        status=409\n+                    )\n+                # The last request sometimes comes twice. This happens because\n+                # nginx sends a 499 error code when the response takes too long.\n+                elif size > int(content_range['stop']) \\\n+                        and size == int(content_range['end']):\n+                    return JsonResponse({'ImportStatus': 1})\n+\n+            with open(temp_filepath, mode) as temp_file:\n+                for chunk in request.FILES['course-data'].chunks():\n+                    temp_file.write(chunk)\n+\n+            size = os.path.getsize(temp_filepath)\n+\n+            if int(content_range['stop']) != int(content_range['end']) - 1:\n+                # More chunks coming\n+                return JsonResponse({\n+                    \"files\": [{\n+                        \"name\": filename,\n+                        \"size\": size,\n+                        \"delete_url\": \"\",\n+                        \"delete_type\": \"\",\n+                        \"thumbnail_url\": \"\"\n+                    }]\n+                })\n+        # Send errors to client with stage at which error occurred.\n+        except Exception as exception:  # pylint: disable=W0703\n+            self._save_request_status(request, courselike_string, -1)\n+            if course_dir.isdir():\n+                shutil.rmtree(course_dir)\n+                log.info(\n+                    \"Course import {0}: Temp data cleared\".format(courselike_key)\n+                )\n+\n+            log.exception(\"error importing course\")\n+            return JsonResponse(\n+                {\n+                    'error_message': str(exception),\n+                    'stage': -1\n+                },\n+                status=400\n+            )\n+\n+        # try-finally block for proper clean up after receiving last chunk.\n+        try:\n+            # This was the last chunk.\n+            log.info(\"Course import {0}: Upload complete\".format(courselike_key))\n+            self._save_request_status(request, courselike_string, 1)\n+\n+            tar_file = tarfile.open(temp_filepath)\n+            try:\n+                safetar_extractall(\n+                    tar_file,\n+                    (course_dir + '/').encode('utf-8'))\n+            except SuspiciousOperation as exc:\n+                self._save_request_status(request, courselike_string, -1)\n+                return JsonResponse(\n+                    {\n+                        'error_message': 'Unsafe tar file. Aborting import.',\n+                        'suspicious_operation_message': exc.args[0],\n+                        'stage': -1\n+                    },\n+                    status=400\n+                )\n+            finally:\n+                tar_file.close()\n+\n+            log.info(\n+                \"Course import {0}: Uploaded file extracted\".format(courselike_key)\n+            )\n+            self._save_request_status(request, courselike_string, 2)\n+\n+            # find the 'course.xml' file\n+            def get_all_files(directory):\n+                \"\"\"\n+                For each file in the directory, yield a 2-tuple of (file-name,\n+                directory-path)\n+                \"\"\"\n+                for dirpath, _dirnames, filenames in os.walk(directory):\n+                    for filename in filenames:\n+                        yield (filename, dirpath)\n+\n+            def get_dir_for_fname(directory, filename):\n+                \"\"\"\n+                Returns the dirpath for the first file found in the directory\n+                with the given name.  If there is no file in the directory with\n+                the specified name, return None.\n+                \"\"\"\n+                for fname, dirpath in get_all_files(directory):\n+                    if fname == filename:\n+                        return dirpath\n+                return None\n+\n+            dirpath = get_dir_for_fname(course_dir, root_name)\n+            if not dirpath:\n+                self._save_request_status(request, courselike_string, -2)\n+                return JsonResponse(\n+                    {\n+\n+                        'error_message': _(\n+                            'Could not find the {0} file in the package.'.\n+                                format(root_name)\n+                        ),\n+                        'stage': -2\n+                    },\n+                    status=415\n+                )\n+\n+            dirpath = os.path.relpath(dirpath, data_root)\n+            logging.debug('found %s at %s', root_name, dirpath)\n+\n+            log.info(\n+                \"Course import %s: Extracted file verified\",\n+                courselike_key\n+            )\n+            self._save_request_status(request, courselike_string, 3)\n+\n+            courselike_items = import_func(\n+                modulestore(),\n+                request.user.id,\n+                settings.GITHUB_REPO_ROOT,\n+                [dirpath],\n+                load_error_modules=False,\n+                static_content_store=contentstore(),\n+                target_id=courselike_key,\n+            )\n+\n+            new_location = courselike_items[0].location\n+            logging.debug('new course at {0}'.format(new_location))\n+\n+            log.info(\n+                \"Course import {0}: Course import successful\".format(courselike_key)\n+            )\n+            self._save_request_status(request, courselike_string, 4)\n+\n+        # Send errors to client with stage at which error occurred.\n+        except Exception as exception:   # pylint: disable=W0703\n+            log.exception(\n+                \"error importing course\"\n+            )\n+            return JsonResponse(\n+                {\n+                    'error_message': str(exception),\n+                    'stage': -cache.get(status_key)\n+                },\n+                status=400\n+            )\n+\n+        finally:\n+            if course_dir.isdir():\n+                shutil.rmtree(course_dir)\n+                log.info(\n+                    \"Course import {0}: Temp data cleared\".format(courselike_key)","path":"openedx/core/djangoapps/import_export/courses/views.py","position":518,"original_position":518,"commit_id":"3de74eb2f5d7694c1c87934e2db1ecf80075e1aa","original_commit_id":"3de74eb2f5d7694c1c87934e2db1ecf80075e1aa","user":{"login":"sarina","id":1985317,"avatar_url":"https://avatars.githubusercontent.com/u/1985317?v=3","gravatar_id":"","url":"https://api.github.com/users/sarina","html_url":"https://github.com/sarina","followers_url":"https://api.github.com/users/sarina/followers","following_url":"https://api.github.com/users/sarina/following{/other_user}","gists_url":"https://api.github.com/users/sarina/gists{/gist_id}","starred_url":"https://api.github.com/users/sarina/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/sarina/subscriptions","organizations_url":"https://api.github.com/users/sarina/orgs","repos_url":"https://api.github.com/users/sarina/repos","events_url":"https://api.github.com/users/sarina/events{/privacy}","received_events_url":"https://api.github.com/users/sarina/received_events","type":"User","site_admin":false},"body":"lazy logging","created_at":"2015-04-28T21:23:51Z","updated_at":"2015-04-28T21:23:51Z","html_url":"https://github.com/edx/edx-platform/pull/6190#discussion_r29290251","pull_request_url":"https://api.github.com/repos/edx/edx-platform/pulls/6190","_links":{"self":{"href":"https://api.github.com/repos/edx/edx-platform/pulls/comments/29290251"},"html":{"href":"https://github.com/edx/edx-platform/pull/6190#discussion_r29290251"},"pull_request":{"href":"https://api.github.com/repos/edx/edx-platform/pulls/6190"}}},"pull_request":{"url":"https://api.github.com/repos/edx/edx-platform/pulls/6190","id":25717411,"html_url":"https://github.com/edx/edx-platform/pull/6190","diff_url":"https://github.com/edx/edx-platform/pull/6190.diff","patch_url":"https://github.com/edx/edx-platform/pull/6190.patch","issue_url":"https://api.github.com/repos/edx/edx-platform/issues/6190","number":6190,"state":"open","locked":false,"title":"Public Course Import/Export API","user":{"login":"bdero","id":919017,"avatar_url":"https://avatars.githubusercontent.com/u/919017?v=3","gravatar_id":"","url":"https://api.github.com/users/bdero","html_url":"https://github.com/bdero","followers_url":"https://api.github.com/users/bdero/followers","following_url":"https://api.github.com/users/bdero/following{/other_user}","gists_url":"https://api.github.com/users/bdero/gists{/gist_id}","starred_url":"https://api.github.com/users/bdero/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/bdero/subscriptions","organizations_url":"https://api.github.com/users/bdero/orgs","repos_url":"https://api.github.com/users/bdero/repos","events_url":"https://api.github.com/users/bdero/events{/privacy}","received_events_url":"https://api.github.com/users/bdero/received_events","type":"User","site_admin":false},"body":"This is a public, shared, versioned, RESTful API for importing and exporting full course content. The code was initially ripped from the existing import/export API in the CMS contentstore djangoapp and wrapped in Django Rest Framework view classes. It's simply a new djangoapp in the *openedx* directory with 4 views:\r\n- GET /api/v1/courses - List courses (course keys) for which the user has full author access\r\n- GET /api/v1/courses/{course_key} - Export a full course in the form of a tarball\r\n- POST /api/v1/courses/{course_key} - Import a full course tarball\r\n- GET /api/v1/courses/{course_key}/import_status/{filename} - View the status of a course import (see https://github.com/edx/edx-platform/pull/6190/files#diff-4c6e0b7531e770ee217a370f8d990ac8R108)\r\n\r\nThis PR includes configuration changes. Most notably, Studio is configured to serve the OAuth2 provider alongside the LMS.\r\n\r\nHere's the relevant thread on the code list: https://groups.google.com/forum/#!msg/edx-code/DmnHWmly25A/ZqjD1zb4o7oJ\r\n\r\nThere are 28 non-covered lines, all of which are missing coverage in the existing CMS API. They're mostly error conditions, such as handling of multipart file upload errors.","created_at":"2014-12-09T03:41:43Z","updated_at":"2015-04-28T21:23:51Z","closed_at":null,"merged_at":null,"merge_commit_sha":"96ea81bd0c3f670dc27ffddd0d473a95f55c55ad","assignee":null,"milestone":null,"commits_url":"https://api.github.com/repos/edx/edx-platform/pulls/6190/commits","review_comments_url":"https://api.github.com/repos/edx/edx-platform/pulls/6190/comments","review_comment_url":"https://api.github.com/repos/edx/edx-platform/pulls/comments{/number}","comments_url":"https://api.github.com/repos/edx/edx-platform/issues/6190/comments","statuses_url":"https://api.github.com/repos/edx/edx-platform/statuses/3de74eb2f5d7694c1c87934e2db1ecf80075e1aa","head":{"label":"mitocw:bdero/import-export-api","ref":"bdero/import-export-api","sha":"3de74eb2f5d7694c1c87934e2db1ecf80075e1aa","user":{"login":"mitocw","id":3009146,"avatar_url":"https://avatars.githubusercontent.com/u/3009146?v=3","gravatar_id":"","url":"https://api.github.com/users/mitocw","html_url":"https://github.com/mitocw","followers_url":"https://api.github.com/users/mitocw/followers","following_url":"https://api.github.com/users/mitocw/following{/other_user}","gists_url":"https://api.github.com/users/mitocw/gists{/gist_id}","starred_url":"https://api.github.com/users/mitocw/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/mitocw/subscriptions","organizations_url":"https://api.github.com/users/mitocw/orgs","repos_url":"https://api.github.com/users/mitocw/repos","events_url":"https://api.github.com/users/mitocw/events{/privacy}","received_events_url":"https://api.github.com/users/mitocw/received_events","type":"Organization","site_admin":false},"repo":{"id":17842334,"name":"edx-platform","full_name":"mitocw/edx-platform","owner":{"login":"mitocw","id":3009146,"avatar_url":"https://avatars.githubusercontent.com/u/3009146?v=3","gravatar_id":"","url":"https://api.github.com/users/mitocw","html_url":"https://github.com/mitocw","followers_url":"https://api.github.com/users/mitocw/followers","following_url":"https://api.github.com/users/mitocw/following{/other_user}","gists_url":"https://api.github.com/users/mitocw/gists{/gist_id}","starred_url":"https://api.github.com/users/mitocw/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/mitocw/subscriptions","organizations_url":"https://api.github.com/users/mitocw/orgs","repos_url":"https://api.github.com/users/mitocw/repos","events_url":"https://api.github.com/users/mitocw/events{/privacy}","received_events_url":"https://api.github.com/users/mitocw/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/mitocw/edx-platform","description":"the edX learning management system (LMS) and course authoring tool, Studio","fork":true,"url":"https://api.github.com/repos/mitocw/edx-platform","forks_url":"https://api.github.com/repos/mitocw/edx-platform/forks","keys_url":"https://api.github.com/repos/mitocw/edx-platform/keys{/key_id}","collaborators_url":"https://api.github.com/repos/mitocw/edx-platform/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/mitocw/edx-platform/teams","hooks_url":"https://api.github.com/repos/mitocw/edx-platform/hooks","issue_events_url":"https://api.github.com/repos/mitocw/edx-platform/issues/events{/number}","events_url":"https://api.github.com/repos/mitocw/edx-platform/events","assignees_url":"https://api.github.com/repos/mitocw/edx-platform/assignees{/user}","branches_url":"https://api.github.com/repos/mitocw/edx-platform/branches{/branch}","tags_url":"https://api.github.com/repos/mitocw/edx-platform/tags","blobs_url":"https://api.github.com/repos/mitocw/edx-platform/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/mitocw/edx-platform/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/mitocw/edx-platform/git/refs{/sha}","trees_url":"https://api.github.com/repos/mitocw/edx-platform/git/trees{/sha}","statuses_url":"https://api.github.com/repos/mitocw/edx-platform/statuses/{sha}","languages_url":"https://api.github.com/repos/mitocw/edx-platform/languages","stargazers_url":"https://api.github.com/repos/mitocw/edx-platform/stargazers","contributors_url":"https://api.github.com/repos/mitocw/edx-platform/contributors","subscribers_url":"https://api.github.com/repos/mitocw/edx-platform/subscribers","subscription_url":"https://api.github.com/repos/mitocw/edx-platform/subscription","commits_url":"https://api.github.com/repos/mitocw/edx-platform/commits{/sha}","git_commits_url":"https://api.github.com/repos/mitocw/edx-platform/git/commits{/sha}","comments_url":"https://api.github.com/repos/mitocw/edx-platform/comments{/number}","issue_comment_url":"https://api.github.com/repos/mitocw/edx-platform/issues/comments{/number}","contents_url":"https://api.github.com/repos/mitocw/edx-platform/contents/{+path}","compare_url":"https://api.github.com/repos/mitocw/edx-platform/compare/{base}...{head}","merges_url":"https://api.github.com/repos/mitocw/edx-platform/merges","archive_url":"https://api.github.com/repos/mitocw/edx-platform/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/mitocw/edx-platform/downloads","issues_url":"https://api.github.com/repos/mitocw/edx-platform/issues{/number}","pulls_url":"https://api.github.com/repos/mitocw/edx-platform/pulls{/number}","milestones_url":"https://api.github.com/repos/mitocw/edx-platform/milestones{/number}","notifications_url":"https://api.github.com/repos/mitocw/edx-platform/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/mitocw/edx-platform/labels{/name}","releases_url":"https://api.github.com/repos/mitocw/edx-platform/releases{/id}","created_at":"2014-03-17T20:32:05Z","updated_at":"2015-04-24T16:17:32Z","pushed_at":"2015-04-28T14:51:46Z","git_url":"git://github.com/mitocw/edx-platform.git","ssh_url":"git@github.com:mitocw/edx-platform.git","clone_url":"https://github.com/mitocw/edx-platform.git","svn_url":"https://github.com/mitocw/edx-platform","homepage":"http://code.edx.org/","size":462540,"stargazers_count":0,"watchers_count":0,"language":"Python","has_issues":true,"has_downloads":true,"has_wiki":true,"has_pages":true,"forks_count":0,"mirror_url":null,"open_issues_count":0,"forks":0,"open_issues":0,"watchers":0,"default_branch":"master"}},"base":{"label":"edx:master","ref":"master","sha":"8a211b48dfddd81f04c00cb5398c9243bf94e5a6","user":{"login":"edx","id":3179841,"avatar_url":"https://avatars.githubusercontent.com/u/3179841?v=3","gravatar_id":"","url":"https://api.github.com/users/edx","html_url":"https://github.com/edx","followers_url":"https://api.github.com/users/edx/followers","following_url":"https://api.github.com/users/edx/following{/other_user}","gists_url":"https://api.github.com/users/edx/gists{/gist_id}","starred_url":"https://api.github.com/users/edx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/edx/subscriptions","organizations_url":"https://api.github.com/users/edx/orgs","repos_url":"https://api.github.com/users/edx/repos","events_url":"https://api.github.com/users/edx/events{/privacy}","received_events_url":"https://api.github.com/users/edx/received_events","type":"Organization","site_admin":false},"repo":{"id":10391073,"name":"edx-platform","full_name":"edx/edx-platform","owner":{"login":"edx","id":3179841,"avatar_url":"https://avatars.githubusercontent.com/u/3179841?v=3","gravatar_id":"","url":"https://api.github.com/users/edx","html_url":"https://github.com/edx","followers_url":"https://api.github.com/users/edx/followers","following_url":"https://api.github.com/users/edx/following{/other_user}","gists_url":"https://api.github.com/users/edx/gists{/gist_id}","starred_url":"https://api.github.com/users/edx/starred{/owner}{/repo}","subscriptions_url":"https://api.github.com/users/edx/subscriptions","organizations_url":"https://api.github.com/users/edx/orgs","repos_url":"https://api.github.com/users/edx/repos","events_url":"https://api.github.com/users/edx/events{/privacy}","received_events_url":"https://api.github.com/users/edx/received_events","type":"Organization","site_admin":false},"private":false,"html_url":"https://github.com/edx/edx-platform","description":"Open edX, the edX platform that powers http://edx.org","fork":false,"url":"https://api.github.com/repos/edx/edx-platform","forks_url":"https://api.github.com/repos/edx/edx-platform/forks","keys_url":"https://api.github.com/repos/edx/edx-platform/keys{/key_id}","collaborators_url":"https://api.github.com/repos/edx/edx-platform/collaborators{/collaborator}","teams_url":"https://api.github.com/repos/edx/edx-platform/teams","hooks_url":"https://api.github.com/repos/edx/edx-platform/hooks","issue_events_url":"https://api.github.com/repos/edx/edx-platform/issues/events{/number}","events_url":"https://api.github.com/repos/edx/edx-platform/events","assignees_url":"https://api.github.com/repos/edx/edx-platform/assignees{/user}","branches_url":"https://api.github.com/repos/edx/edx-platform/branches{/branch}","tags_url":"https://api.github.com/repos/edx/edx-platform/tags","blobs_url":"https://api.github.com/repos/edx/edx-platform/git/blobs{/sha}","git_tags_url":"https://api.github.com/repos/edx/edx-platform/git/tags{/sha}","git_refs_url":"https://api.github.com/repos/edx/edx-platform/git/refs{/sha}","trees_url":"https://api.github.com/repos/edx/edx-platform/git/trees{/sha}","statuses_url":"https://api.github.com/repos/edx/edx-platform/statuses/{sha}","languages_url":"https://api.github.com/repos/edx/edx-platform/languages","stargazers_url":"https://api.github.com/repos/edx/edx-platform/stargazers","contributors_url":"https://api.github.com/repos/edx/edx-platform/contributors","subscribers_url":"https://api.github.com/repos/edx/edx-platform/subscribers","subscription_url":"https://api.github.com/repos/edx/edx-platform/subscription","commits_url":"https://api.github.com/repos/edx/edx-platform/commits{/sha}","git_commits_url":"https://api.github.com/repos/edx/edx-platform/git/commits{/sha}","comments_url":"https://api.github.com/repos/edx/edx-platform/comments{/number}","issue_comment_url":"https://api.github.com/repos/edx/edx-platform/issues/comments{/number}","contents_url":"https://api.github.com/repos/edx/edx-platform/contents/{+path}","compare_url":"https://api.github.com/repos/edx/edx-platform/compare/{base}...{head}","merges_url":"https://api.github.com/repos/edx/edx-platform/merges","archive_url":"https://api.github.com/repos/edx/edx-platform/{archive_format}{/ref}","downloads_url":"https://api.github.com/repos/edx/edx-platform/downloads","issues_url":"https://api.github.com/repos/edx/edx-platform/issues{/number}","pulls_url":"https://api.github.com/repos/edx/edx-platform/pulls{/number}","milestones_url":"https://api.github.com/repos/edx/edx-platform/milestones{/number}","notifications_url":"https://api.github.com/repos/edx/edx-platform/notifications{?since,all,participating}","labels_url":"https://api.github.com/repos/edx/edx-platform/labels{/name}","releases_url":"https://api.github.com/repos/edx/edx-platform/releases{/id}","created_at":"2013-05-30T20:20:38Z","updated_at":"2015-04-28T21:21:37Z","pushed_at":"2015-04-28T21:05:24Z","git_url":"git://github.com/edx/edx-platform.git","ssh_url":"git@github.com:edx/edx-platform.git","clone_url":"https://github.com/edx/edx-platform.git","svn_url":"https://github.com/edx/edx-platform","homepage":"http://openedx.org/","size":1882494,"stargazers_count":1897,"watchers_count":1897,"language":"Python","has_issues":false,"has_downloads":true,"has_wiki":true,"has_pages":true,"forks_count":1078,"mirror_url":null,"open_issues_count":163,"forks":1078,"open_issues":163,"watchers":1897,"default_branch":"master"}},"_links":{"self":{"href":"https://api.github.com/repos/edx/edx-platform/pulls/6190"},"html":{"href":"https://github.com/edx/edx-platform/pull/6190"},"issue":{"href":"https://api.github.com/repos/edx/edx-platform/issues/6190"},"comments":{"href":"https://api.github.com/repos/edx/edx-platform/issues/6190/comments"},"review_comments":{"href":"https://api.github.com/repos/edx/edx-platform/pulls/6190/comments"},"review_comment":{"href":"https://api.github.com/repos/edx/edx-platform/pulls/comments{/number}"},"commits":{"href":"https://api.github.com/repos/edx/edx-platform/pulls/6190/commits"},"statuses":{"href":"https://api.github.com/repos/edx/edx-platform/statuses/3de74eb2f5d7694c1c87934e2db1ecf80075e1aa"}}}}