Commit 7ebd7d46 by Oleg Marshev

Create application providing API for Student Notes.

parent daf7bfc9
[run]
omit = notesserver/settings*
*wsgi.py
# Python artifacts
*.pyc
# Tests / Coverage reports
.coverage
.tox
coverage/
[pep8]
ignore=E501
max_line_length=119
...@@ -5,11 +5,12 @@ services: ...@@ -5,11 +5,12 @@ services:
- elasticsearch - elasticsearch
install: install:
- pip install -r requirements/test.py - pip install -r requirements/test.txt
- git fetch origin master:refs/remotes/origin/master # https://github.com/edx/diff-cover#troubleshooting
- pip install coveralls - pip install coveralls
script: script:
- make test - make validate
after_success: after_success:
- coveralls - coveralls
Oleg Marshev <oleg@edx.org>
How To Contribute
=================
Contributions are very welcome.
Please read `How To Contribute <https://github.com/edx/edx-platform/blob/master/CONTRIBUTING.rst>`_ for details.
Even though it was written with ``edx-platform`` in mind, the guidelines
should be followed for Open edX code in general.
This diff is collapsed. Click to expand it.
test: PACKAGES = notesserver notesapi
./manage.py test --settings=notesserver.settings.test --with-coverage --cover-package=notesserver,notesapi \
--exclude-dir=notesserver/settings --cover-inclusive --cover-branches validate: test.requirements test coverage
test: clean
./manage.py test --settings=notesserver.settings.test --with-coverage --with-ignore-docstrings \
--exclude-dir=notesserver/settings --cover-inclusive --cover-branches \
--cover-html --cover-html-dir=build/coverage/html/ \
--cover-xml --cover-xml-file=build/coverage/coverage.xml \
$(foreach package,$(PACKAGES),--cover-package=$(package)) \
$(PACKAGES)
run: run:
./manage.py runserver 0.0.0.0:8042 ./manage.py runserver 0.0.0.0:8042
...@@ -8,3 +16,29 @@ run: ...@@ -8,3 +16,29 @@ run:
shell: shell:
./manage.py shell ./manage.py shell
clean:
coverage erase
quality:
pep8 --config=.pep8 $(PACKAGES)
pylint $(PACKAGES)
diff-coverage:
diff-cover build/coverage/coverage.xml --html-report build/coverage/diff_cover.html
diff-quality:
diff-quality --violations=pep8 --html-report build/coverage/diff_quality_pep8.html
diff-quality --violations=pylint --html-report build/coverage/diff_quality_pylint.html
coverage: diff-coverage diff-quality
create-index:
python manage.py create_index
requirements:
pip install -q -r requirements/base.txt --exists-action=w
test.requirements: requirements
pip install -q -r requirements/test.txt --exists-action=w
develop: test.requirements
EdX Student Notes API
=====================
Part of `edX code`__.
__ http://code.edx.org/
edX Student Notes API |build-status| |coverage-status|
======================================================
This is a backend store for edX Student Notes.
Overview
--------
The edX Notes API is designed to be compatible with the
`Annotator <http://annotatorjs.org/>`__.
Getting Started
---------------
1. You'll need a recent version `ElasticSearch <http://elasticsearch.org>`__ (>=1.0.0)
installed.
2. Install the requirements:
::
$ make develop
3. Create index and put mapping:
::
$ make create-index
4. Run the server:
::
$ make run
Running Tests
-------------
Run ``make validate`` install the requirements, run the tests, and run
lint.
License
-------
The code in this repository is licensed under version 3 of the AGPL unless
otherwise noted.
Please see ``LICENSE.txt`` for details.
How To Contribute
-----------------
Contributions are very welcome.
Please read `How To Contribute <https://github.com/edx/edx-platform/blob/master/CONTRIBUTING.rst>`_ for details.
Even though it was written with ``edx-platform`` in mind, the guidelines
should be followed for Open edX code in general.
Reporting Security Issues
-------------------------
Please do not report security issues in public. Please email security@edx.org
Mailing List and IRC Channel
----------------------------
You can discuss this code on the `edx-code Google Group`__ or in the
``edx-code`` IRC channel on Freenode.
__ https://groups.google.com/forum/#!forum/edx-code
.. |build-status| image:: https://travis-ci.org/edx/edx-notes-api.svg?branch=master
:target: https://travis-ci.org/edx/edx-notes-api
.. |coverage-status| image:: https://coveralls.io/repos/edx/edx-notes-api/badge.png?branch=master
:target: https://coveralls.io/r/edx/edx-notes-api?branch=master
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "notesserver.settings.dev")
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
from django.core.management.base import BaseCommand
from annotator.annotation import Annotation
class Command(BaseCommand):
help = 'Creates the mapping in the index.'
def handle(self, *args, **options):
Annotation.create_all()
from django.conf.urls import patterns, url, include
urlpatterns = patterns(
'',
url(r'^v1/', include('notesapi.v1.urls', namespace='v1')),
)
from rest_framework.permissions import BasePermission
class HasAccessToken(BasePermission):
"""
Allow requests having valid ID Token.
"""
def has_permission(self, request, view):
return True
class MockConsumer(object):
def __init__(self, key='mockconsumer'):
self.key = key
self.secret = 'top-secret'
self.ttl = 86400
class MockUser(object):
def __init__(self, id='alice', consumer=None):
self.id = id
self.consumer = MockConsumer(consumer if consumer is not None else 'mockconsumer')
self.is_admin = False
class MockAuthenticator(object):
def request_user(self, request):
return MockUser()
def mock_authorizer(*args, **kwargs):
return True
from django.conf.urls import patterns, url
from django.views.generic import RedirectView
from django.core.urlresolvers import reverse_lazy
from notesapi.v1.views import AnnotationListView, AnnotationDetailView, AnnotationSearchView
urlpatterns = patterns(
'',
url(r'^annotations/$', AnnotationListView.as_view(), name='annotations'),
url(r'^annotations/(?P<annotation_id>[a-zA-Z0-9_-]+)$', AnnotationDetailView.as_view(), name='annotations_detail'),
url(r'^search/$', AnnotationSearchView.as_view(), name='annotations_search'),
url(r'^status/$', RedirectView.as_view(url=reverse_lazy('status')), name='status'),
)
from django.conf import settings
from django.core.urlresolvers import reverse
from rest_framework import status
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from annotator.annotation import Annotation
CREATE_FILTER_FIELDS = ('updated', 'created', 'consumer', 'id')
UPDATE_FILTER_FIELDS = ('updated', 'created', 'user', 'consumer')
class AnnotationSearchView(APIView):
"""
Search annotations.
"""
permission_classes = (AllowAny,)
def get(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Search annotations.
This method supports the limit and offset query parameters for paging
through results.
"""
params = self.request.QUERY_PARAMS.dict()
if 'offset' in params:
kwargs['offset'] = _convert_to_int(params.pop('offset'))
if 'limit' in params and _convert_to_int(params['limit']) is not None:
kwargs['limit'] = _convert_to_int(params.pop('limit'))
elif 'limit' in params and _convert_to_int(params['limit']) is None: # bad value
params.pop('limit')
kwargs['limit'] = settings.RESULTS_DEFAULT_SIZE
else:
# default
kwargs['limit'] = settings.RESULTS_DEFAULT_SIZE
# All remaining parameters are considered searched fields.
kwargs['query'] = params
results = Annotation.search(**kwargs)
total = Annotation.count(**kwargs)
return Response({'total': total, 'rows': results})
class AnnotationListView(APIView):
"""
List all annotations or create.
"""
permission_classes = (AllowAny,)
def get(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Get a list of all annotations.
"""
self.kwargs['query'] = self.request.QUERY_PARAMS.dict()
annotations = Annotation.search(**kwargs)
return Response(annotations)
def post(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Create a new annotation.
Returns 400 request if bad payload is sent or it was empty object.
"""
if 'id' in self.request.DATA:
return Response(status=status.HTTP_400_BAD_REQUEST)
filtered_payload = _filter_input(self.request.DATA, CREATE_FILTER_FIELDS)
if len(filtered_payload) == 0:
return Response(status=status.HTTP_400_BAD_REQUEST)
annotation = Annotation(filtered_payload)
annotation.save(refresh=True)
location = reverse('api:v1:annotations_detail', kwargs={'annotation_id': annotation['id']})
return Response(annotation, status=status.HTTP_201_CREATED, headers={'Location': location})
class AnnotationDetailView(APIView):
"""
Annotation detail view.
"""
permission_classes = (AllowAny,)
UPDATE_FILTER_FIELDS = ('updated', 'created', 'user', 'consumer')
def get(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Get an existing annotation.
"""
annotation_id = self.kwargs.get('annotation_id')
annotation = Annotation.fetch(annotation_id)
if not annotation:
return Response(annotation, status=status.HTTP_404_NOT_FOUND)
return Response(annotation)
def put(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Update an existing annotation.
"""
annotation_id = self.kwargs.get('annotation_id')
annotation = Annotation.fetch(annotation_id)
if not annotation:
return Response('Annotation not found! No update performed.', status=status.HTTP_404_NOT_FOUND)
if self.request.DATA is not None:
updated = _filter_input(self.request.DATA, UPDATE_FILTER_FIELDS)
updated['id'] = annotation_id # use id from URL, regardless of what arrives in JSON payload.
annotation.update(updated)
refresh = self.kwargs.get('refresh') != 'false'
annotation.save(refresh=refresh)
return Response(annotation)
def delete(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Delete an annotation.
"""
annotation_id = self.kwargs.get('annotation_id')
annotation = Annotation.fetch(annotation_id)
if not annotation:
return Response('Annotation not found! No delete performed.', status=status.HTTP_404_NOT_FOUND)
annotation.delete()
# Annotation deleted successfully.
return Response(status=status.HTTP_204_NO_CONTENT)
def _filter_input(annotation, fields):
"""
Pop given fields from annotation.
"""
for field in fields:
annotation.pop(field, None)
return annotation
def _convert_to_int(value, default=None):
"""
Convert given value to int.
"""
try:
return int(value or default)
except ValueError:
return default
import os
import json
import sys
DEBUG = False
TEMPLATE_DEBUG = False
USE_TZ = True
TIME_ZONE = 'UTC'
# This value needs to be overriden in production.
SECRET_KEY = '*^owi*4%!%9=#h@app!l^$jz8(c*q297^)4&4yn^#_m#fq=z#l'
ROOT_URLCONF = 'notesserver.urls'
MIDDLEWARE_CLASSES = (
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
)
INSTALLED_APPS = (
'django.contrib.staticfiles',
'rest_framework',
'rest_framework_swagger',
'corsheaders',
'notesapi',
'notesapi.v1',
)
STATIC_URL = '/static/'
WSGI_APPLICATION = 'notesserver.wsgi.application'
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'stream': sys.stderr,
'formatter': 'standard',
},
},
'formatters': {
'standard': {
'format': '%(asctime)s %(levelname)s %(process)d [%(name)s] %(filename)s:%(lineno)d - %(message)s',
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'ERROR',
'propagate': False,
},
'notesserver': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True
},
'elasticsearch.trace': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True
},
'elasticsearch': {
'handlers': ['console'],
'level': 'WARNING',
'propagate': True
},
'annotator.elasticsearch': {
'handlers': ['console'],
'level': 'WARNING',
'propagate': True
},
'urllib3': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True
},
},
}
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated'
]
}
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_HEADERS = (
'x-requested-with',
'content-type',
'accept',
'origin',
'authorization',
'x-csrftoken',
'x-annotator-auth-token',
)
import annotator
from annotator import es
from .common import *
DEBUG = True
ELASTICSEARCH_URL = 'http://127.0.0.1:9200'
ELASTICSEARCH_INDEX = 'edx-notes-dev'
# Overwrite default annotator-store elasticsearch settings.
es.host = ELASTICSEARCH_URL
es.index = ELASTICSEARCH_INDEX
# Number of rows to return by default in result.
RESULTS_DEFAULT_SIZE = 25
# Max number of rows to return in result.
RESULTS_MAX_SIZE = 250
# Override default annotator-store elasticsearch settings.
es.host = ELASTICSEARCH_URL
es.index = ELASTICSEARCH_INDEX
annotator.elasticsearch.RESULTS_MAX_SIZE = RESULTS_MAX_SIZE
from .common import *
DEBUG = False
ALLOWED_HOSTS = ['*']
from annotator import es
import annotator
from .common import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
}
}
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
INSTALLED_APPS += ('django_nose',)
ELASTICSEARCH_URL = 'http://127.0.0.1:9200'
ELASTICSEARCH_INDEX = 'edx-notes-test'
# Number of rows to return by default in result.
RESULTS_DEFAULT_SIZE = 25
# Max number of rows to return in result.
RESULTS_MAX_SIZE = 250
# Override default annotator-store elasticsearch settings.
es.host = ELASTICSEARCH_URL
es.index = ELASTICSEARCH_INDEX
annotator.elasticsearch.RESULTS_MAX_SIZE = RESULTS_MAX_SIZE
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
class OperationalEndpointsTest(APITestCase):
"""
Tests for operational endpoints.
"""
def test_status(self):
"""
Test if server is alive.
"""
response = self.client.get(reverse('status'))
self.assertEquals(response.status_code, 200)
def test_root(self):
"""
Test root endpoint.
"""
response = self.client.get(reverse('root'))
self.assertEquals(response.status_code, 200)
self.assertEquals(
response.data,
{
"name": "edX Notes API",
"version": "1"
}
)
from django.conf.urls import patterns, url, include
from notesserver.views import StatusView
urlpatterns = patterns(
'',
url(r'^status/$', StatusView.as_view(), name='status'),
url(r'^$', 'notesserver.views.root', name='root'),
url(r'^api/', include('notesapi.urls', namespace='api')),
)
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.decorators import api_view, permission_classes
@api_view(['GET'])
@permission_classes([AllowAny])
def root(request): # pylint: disable=unused-argument
"""
Root view.
"""
return Response({
"name": "edX Notes API",
"version": "1"
})
class StatusView(APIView):
"""
Determine if server is alive.
"""
permission_classes = (AllowAny,)
def get(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Service status.
"""
return Response()
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "notesserver.settings.production")
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()
This diff is collapsed. Click to expand it.
Django==1.7.1
requests==2.4.3
djangorestframework==2.4.4
django-rest-swagger==0.2.0
elasticsearch==1.2.0
annotator==0.12.0
django-cors-headers==0.13
-r base.txt
django_nose==1.2
mock==1.0.1
coverage==3.7.1
nose-exclude==0.2.0
nose-ignore-docstring==0.2
pep8==1.5.7
pylint==1.4.0
diff-cover==0.7.2
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment