Commit cb07653c by Oleg Marshev

Add model.

parent 5ed1b732
language: python language: python
python: 2.7 python: 2.7
sudo: false
services:
- elasticsearch
install: install:
- scripts/travis/install.sh
- scripts/travis/setup.sh
- pip install -r requirements/test.txt - pip install -r requirements/test.txt
- git fetch origin master:refs/remotes/origin/master # https://github.com/edx/diff-cover#troubleshooting - git fetch origin master:refs/remotes/origin/master # https://github.com/edx/diff-cover#troubleshooting
- pip install coveralls - pip install coveralls
...@@ -14,3 +14,8 @@ script: ...@@ -14,3 +14,8 @@ script:
after_success: after_success:
- coveralls - coveralls
env:
- ESVER=-
- ESVER=0.90.11
- ESVER=1.4.2
...@@ -3,9 +3,16 @@ PACKAGES = notesserver notesapi ...@@ -3,9 +3,16 @@ PACKAGES = notesserver notesapi
validate: test.requirements test coverage validate: test.requirements test coverage
ifeq ($(ESVER),-)
test_settings = notesserver.settings.test_es_disabled
else
test_settings = notesserver.settings.test
endif
test: clean test: clean
./manage.py test --settings=notesserver.settings.test --with-coverage --with-ignore-docstrings \ ./manage.py test --settings=$(test_settings) --with-coverage --with-ignore-docstrings \
--exclude-dir=notesserver/settings --cover-inclusive --cover-branches \ --exclude-dir=notesserver/settings --cover-inclusive --cover-branches \
--ignore-files=search_indexes.py --ignore-files=highlight.py\
--cover-html --cover-html-dir=build/coverage/html/ \ --cover-html --cover-html-dir=build/coverage/html/ \
--cover-xml --cover-xml-file=build/coverage/coverage.xml \ --cover-xml --cover-xml-file=build/coverage/coverage.xml \
$(foreach package,$(PACKAGES),--cover-package=$(package)) \ $(foreach package,$(PACKAGES),--cover-package=$(package)) \
...@@ -34,12 +41,15 @@ diff-quality: ...@@ -34,12 +41,15 @@ diff-quality:
coverage: diff-coverage diff-quality coverage: diff-coverage diff-quality
create-index: create-index:
python manage.py create_index python manage.py rebuild_index
requirements: requirements:
pip install -q -r requirements/base.txt --exists-action=w pip install -q -r requirements/base.txt --exists-action=w
test.requirements: requirements test.requirements: requirements
pip install -q -r requirements/test.txt --exists-action=w pip install -q -r requirements/test.txt --exists-action=w
@# unicode QUERY_PARAMS are being improperly decoded in test client
@# remove after https://github.com/tomchristie/django-rest-framework/issues/1891 is fixed
pip install -q -e git+https://github.com/tymofij/django-rest-framework.git@bugfix/test-unicode-query-params#egg=djangorestframework
develop: test.requirements develop: test.requirements
...@@ -11,13 +11,12 @@ Overview ...@@ -11,13 +11,12 @@ Overview
-------- --------
The edX Notes API is designed to be compatible with the The edX Notes API is designed to be compatible with the
`Annotator <http://annotatorjs.org/>`__. `Annotator <http://annotatorjs.org/>`__. Can be run with up to date ElasticSearch or legacy 0.90.x.
Getting Started Getting Started
--------------- ---------------
1. You'll need a recent version `ElasticSearch <http://elasticsearch.org>`__ (>=1.0.0) 1. You'll need an `ElasticSearch <http://elasticsearch.org>`__ installed.
installed.
2. Install the requirements: 2. Install the requirements:
...@@ -37,6 +36,19 @@ installed. ...@@ -37,6 +36,19 @@ installed.
$ make run $ make run
Configuration:
--------------
``CLIENT_ID`` - OAuth2 Client ID, which is to be found in ``aud`` field of IDTokens which authorize users
``CLIENT_SECRET`` - secret with which IDTokens should be encoded
``ES_DISABLED`` - set to True when you need to run the service without ElasticSearch support.
e.g if it became corrupted and you're rebuilding the index, while still serving users
through MySQL
``HAYSTACK_CONNECTIONS['default']['url']`` - Your ElasticSearch URL
Running Tests Running Tests
------------- -------------
......
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()
import json
from django.db import models
from django.core.exceptions import ValidationError
class Note(models.Model):
"""
Annotation model.
"""
user_id = models.CharField(max_length=255, db_index=True, help_text="Anonymized user id, not course specific")
course_id = models.CharField(max_length=255, db_index=True)
usage_id = models.CharField(max_length=255, help_text="ID of XBlock where the text comes from")
quote = models.TextField(default="")
text = models.TextField(default="", help_text="Student's thoughts on the quote")
ranges = models.TextField(help_text="JSON, describes position of quote in the source text")
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
@classmethod
def create(cls, note_dict):
"""
Create the note object.
"""
if not isinstance(note_dict, dict):
raise ValidationError('Note must be a dictionary.')
if len(note_dict) == 0:
raise ValidationError('Note must have a body.')
ranges = note_dict.get('ranges', list())
if len(ranges) < 1:
raise ValidationError('Note must contain at least one range.')
note_dict['ranges'] = json.dumps(ranges)
note_dict['user_id'] = note_dict.pop('user', None)
return cls(**note_dict)
def as_dict(self):
"""
Returns the note object as a dictionary.
"""
created = self.created.isoformat() if self.created else None
updated = self.updated.isoformat() if self.updated else None
return {
'id': str(self.pk),
'user': self.user_id,
'course_id': self.course_id,
'usage_id': self.usage_id,
'text': self.text,
'quote': self.quote,
'ranges': json.loads(self.ranges),
'created': created,
'updated': updated,
}
...@@ -40,21 +40,26 @@ class HasAccessToken(BasePermission): ...@@ -40,21 +40,26 @@ class HasAccessToken(BasePermission):
auth_user = data['sub'] auth_user = data['sub']
if data['aud'] != settings.CLIENT_ID: if data['aud'] != settings.CLIENT_ID:
raise TokenWrongIssuer raise TokenWrongIssuer
user_found = False
for request_field in ('GET', 'POST', 'DATA'): for request_field in ('GET', 'POST', 'DATA'):
if 'user' in getattr(request, request_field): if 'user' in getattr(request, request_field):
req_user = getattr(request, request_field)['user'] req_user = getattr(request, request_field)['user']
if req_user == auth_user: if req_user == auth_user:
return True user_found = True
# but we do not break or return here,
# because `user` may be present in more than one field (GET, POST)
# and we must make sure that all of them are correct
else: else:
logger.debug("Token user {auth_user} did not match {field} user {req_user}".format( logger.debug("Token user %s did not match %s user %s", auth_user, request_field, req_user)
auth_user=auth_user, field=request_field, req_user=req_user
))
return False return False
logger.info("No user was present to compare in GET, POST or DATA") if user_found:
return True
else:
logger.info("No user was present to compare in GET, POST or DATA")
except jwt.ExpiredSignature: except jwt.ExpiredSignature:
logger.debug("Token was expired: {}".format(token)) logger.debug("Token was expired: %s", token)
except jwt.DecodeError: except jwt.DecodeError:
logger.debug("Could not decode token {}".format(token)) logger.debug("Could not decode token %s", token)
except TokenWrongIssuer: except TokenWrongIssuer:
logger.debug("Token has wrong issuer {}".format(token)) logger.debug("Token has wrong issuer %s", token)
return False return False
\ No newline at end of file
from haystack import indexes
from .models import Note
class NoteIndex(indexes.SearchIndex, indexes.Indexable):
user = indexes.CharField(model_attr='user_id', indexed=False)
course_id = indexes.CharField(model_attr='course_id', indexed=False)
usage_id = indexes.CharField(model_attr='usage_id', indexed=False)
quote = indexes.CharField(model_attr='quote')
text = indexes.CharField(document=True, model_attr='text')
ranges = indexes.CharField(model_attr='ranges', indexed=False)
created = indexes.DateTimeField(model_attr='created')
updated = indexes.DateTimeField(model_attr='updated')
def get_model(self):
return Note
def index_queryset(self, using=None):
"""Used when the entire index for model is updated."""
return self.get_model().objects.all()
from unittest import TestCase
from notesapi.v1.models import Note
from django.core.exceptions import ValidationError
class NoteTest(TestCase):
def setUp(self):
self.note_dict = {
"user": u"test_user_id",
"usage_id": u"i4x://org/course/html/52aa9816425a4ce98a07625b8cb70811",
"course_id": u"org/course/run",
"text": u"test note text",
"quote": u"test note quote",
"ranges": [
{
"start": u"/p[1]",
"end": u"/p[1]",
"startOffset": 0,
"endOffset": 10,
}
],
}
def test_create_valid_note(self):
note = Note.create(self.note_dict.copy())
note.save()
result_note = note.as_dict()
del result_note['id']
del result_note['created']
del result_note['updated']
self.assertEqual(result_note, self.note_dict)
def test_create_invalid_note(self):
note = Note()
for empty_type in (None, '', []):
with self.assertRaises(ValidationError):
note.create(empty_type)
def test_must_have_fields_create(self):
for field in ['user', 'usage_id', 'course_id', 'ranges']:
payload = self.note_dict.copy()
payload.pop(field)
with self.assertRaises(ValidationError):
note = Note.create(payload)
note.full_clean()
from django.conf.urls import patterns, url 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 from notesapi.v1.views import AnnotationListView, AnnotationDetailView, AnnotationSearchView
urlpatterns = patterns( urlpatterns = patterns(
'', '',
url(r'^annotations/$', AnnotationListView.as_view(), name='annotations'), 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'^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'^search/$', AnnotationSearchView.as_view(), name='annotations_search'),
) )
import logging
import json
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.core.exceptions import ValidationError
from rest_framework import status from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from annotator.annotation import Annotation from notesapi.v1.models import Note
if not settings.ES_DISABLED:
from notesserver.highlight import SearchQuerySet
CREATE_FILTER_FIELDS = ('updated', 'created', 'consumer', 'id') log = logging.getLogger(__name__)
UPDATE_FILTER_FIELDS = ('updated', 'created', 'user', 'consumer')
class AnnotationSearchView(APIView): class AnnotationSearchView(APIView):
""" """
Search annotations. Search annotations.
""" """
def get(self, *args, **kwargs): # pylint: disable=unused-argument def get(self, *args, **kwargs): # pylint: disable=unused-argument
""" """
Search annotations. Search annotations in most appropriate storage
"""
# search in DB when ES is not available or there is no need to bother it
if settings.ES_DISABLED or 'text' not in self.request.QUERY_PARAMS.dict():
results = self.get_from_db(*args, **kwargs)
else:
results = self.get_from_es(*args, **kwargs)
return Response({'total': len(results), 'rows': results})
This method supports the limit and offset query parameters for paging def get_from_db(self, *args, **kwargs): # pylint: disable=unused-argument
through results. """
Search annotations in database
""" """
params = self.request.QUERY_PARAMS.dict() params = self.request.QUERY_PARAMS.dict()
query = Note.objects.filter(
**{f: v for (f, v) in params.items() if f in ('course_id', 'usage_id')}
).order_by('-updated')
if 'offset' in params: if 'user' in params:
kwargs['offset'] = _convert_to_int(params.pop('offset')) query = query.filter(user_id=params['user'])
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. if 'text' in params:
kwargs['query'] = params query = query.filter(text__icontains=params['text'])
results = Annotation.search(**kwargs) return [note.as_dict() for note in query]
total = Annotation.count(**kwargs)
return Response({'total': total, 'rows': results}) def get_from_es(self, *args, **kwargs): # pylint: disable=unused-argument
"""
Search annotations in ElasticSearch
"""
params = self.request.QUERY_PARAMS.dict()
query = SearchQuerySet().models(Note).filter(
**{f: v for (f, v) in params.items() if f in ('user', 'course_id', 'usage_id', 'text')}
).order_by('-updated')
if params.get('highlight'):
tag = params.get('highlight_tag', 'em')
klass = params.get('highlight_class')
opts = {
'pre_tags': ['<{tag}{klass_str}>'.format(
tag=tag,
klass_str=' class="{}"'.format(klass) if klass else ''
)],
'post_tags': ['</{tag}>'.format(tag=tag)],
}
query = query.highlight(**opts)
results = []
for item in query:
note_dict = item.get_stored_fields()
note_dict['ranges'] = json.loads(item.ranges)
note_dict['id'] = str(item.pk)
if item.highlighted:
note_dict['text'] = item.highlighted[0]
results.append(note_dict)
return results
class AnnotationListView(APIView): class AnnotationListView(APIView):
...@@ -55,11 +91,14 @@ class AnnotationListView(APIView): ...@@ -55,11 +91,14 @@ class AnnotationListView(APIView):
""" """
Get a list of all annotations. Get a list of all annotations.
""" """
self.kwargs['query'] = self.request.QUERY_PARAMS.dict() params = self.request.QUERY_PARAMS.dict()
if 'course_id' not in params:
return Response(status=status.HTTP_400_BAD_REQUEST)
annotations = Annotation.search(**kwargs) results = Note.objects.filter(course_id=params['course_id'], user_id=params['user']).order_by('-updated')
return Response(annotations) return Response([result.as_dict() for result in results])
def post(self, *args, **kwargs): # pylint: disable=unused-argument def post(self, *args, **kwargs): # pylint: disable=unused-argument
""" """
...@@ -70,17 +109,18 @@ class AnnotationListView(APIView): ...@@ -70,17 +109,18 @@ class AnnotationListView(APIView):
if 'id' in self.request.DATA: if 'id' in self.request.DATA:
return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_400_BAD_REQUEST)
filtered_payload = _filter_input(self.request.DATA, CREATE_FILTER_FIELDS) try:
note = Note.create(self.request.DATA)
if len(filtered_payload) == 0: note.full_clean()
except ValidationError as error:
log.debug(error, exc_info=True)
return Response(status=status.HTTP_400_BAD_REQUEST) return Response(status=status.HTTP_400_BAD_REQUEST)
annotation = Annotation(filtered_payload) note.save()
annotation.save(refresh=True)
location = reverse('api:v1:annotations_detail', kwargs={'annotation_id': annotation['id']}) location = reverse('api:v1:annotations_detail', kwargs={'annotation_id': note.id})
return Response(annotation, status=status.HTTP_201_CREATED, headers={'Location': location}) return Response(note.as_dict(), status=status.HTTP_201_CREATED, headers={'Location': location})
class AnnotationDetailView(APIView): class AnnotationDetailView(APIView):
...@@ -94,66 +134,49 @@ class AnnotationDetailView(APIView): ...@@ -94,66 +134,49 @@ class AnnotationDetailView(APIView):
""" """
Get an existing annotation. Get an existing annotation.
""" """
annotation_id = self.kwargs.get('annotation_id') note_id = self.kwargs.get('annotation_id')
annotation = Annotation.fetch(annotation_id)
if not annotation: try:
return Response(annotation, status=status.HTTP_404_NOT_FOUND) note = Note.objects.get(id=note_id)
except Note.DoesNotExist:
return Response('Annotation not found!', status=status.HTTP_404_NOT_FOUND)
return Response(annotation) return Response(note.as_dict())
def put(self, *args, **kwargs): # pylint: disable=unused-argument def put(self, *args, **kwargs): # pylint: disable=unused-argument
""" """
Update an existing annotation. Update an existing annotation.
""" """
annotation_id = self.kwargs.get('annotation_id') note_id = self.kwargs.get('annotation_id')
annotation = Annotation.fetch(annotation_id)
if not annotation: try:
note = Note.objects.get(id=note_id)
except Note.DoesNotExist:
return Response('Annotation not found! No update performed.', status=status.HTTP_404_NOT_FOUND) return Response('Annotation not found! No update performed.', status=status.HTTP_404_NOT_FOUND)
if self.request.DATA is not None: try:
updated = _filter_input(self.request.DATA, UPDATE_FILTER_FIELDS) note.text = self.request.data['text']
updated['id'] = annotation_id # use id from URL, regardless of what arrives in JSON payload. note.full_clean()
except KeyError as error:
annotation.update(updated) log.debug(error, exc_info=True)
return Response(status=status.HTTP_400_BAD_REQUEST)
refresh = self.kwargs.get('refresh') != 'false' note.save()
annotation.save(refresh=refresh)
return Response(annotation) return Response(note.as_dict())
def delete(self, *args, **kwargs): # pylint: disable=unused-argument def delete(self, *args, **kwargs): # pylint: disable=unused-argument
""" """
Delete an annotation. Delete an annotation.
""" """
annotation_id = self.kwargs.get('annotation_id') note_id = self.kwargs.get('annotation_id')
annotation = Annotation.fetch(annotation_id)
if not annotation: try:
return Response('Annotation not found! No delete performed.', status=status.HTTP_404_NOT_FOUND) note = Note.objects.get(id=note_id)
except Note.DoesNotExist:
return Response('Annotation not found! No update performed.', status=status.HTTP_404_NOT_FOUND)
annotation.delete() note.delete()
# Annotation deleted successfully. # Annotation deleted successfully.
return Response(status=status.HTTP_204_NO_CONTENT) 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
"""
django-haystack does not support passing additional highlighting parameters
to backends, so we use our subclassed SearchQuerySet which does,
and subclassed ElasticsearchSearchBackend which passes them to ES
"""
import haystack
from haystack.backends.elasticsearch_backend import (
ElasticsearchSearchEngine as OrigElasticsearchSearchEngine,
ElasticsearchSearchQuery as OrigElasticsearchSearchQuery,
ElasticsearchSearchBackend as OrigElasticsearchSearchBackend)
from haystack.query import SearchQuerySet as OrigSearchQuerySet
class SearchQuerySet(OrigSearchQuerySet):
def highlight(self, **kwargs):
"""Adds highlighting to the results."""
clone = self._clone()
clone.query.add_highlight(**kwargs)
return clone
class ElasticsearchSearchQuery(OrigElasticsearchSearchQuery):
def add_highlight(self, **kwargs):
"""Adds highlighting to the search results."""
self.highlight = kwargs or True
class ElasticsearchSearchBackend(OrigElasticsearchSearchBackend):
"""
Subclassed backend that lets user modify highlighting options
"""
def build_search_kwargs(self, *args, **kwargs):
res = super(ElasticsearchSearchBackend, self).build_search_kwargs(*args, **kwargs)
index = haystack.connections[self.connection_alias].get_unified_index()
content_field = index.document_field
highlight = kwargs.get('highlight')
if highlight:
highlight_options = {
'fields': {
content_field: {'store': 'yes'},
}
}
if isinstance(highlight, dict):
highlight_options.update(highlight)
res['highlight'] = highlight_options
return res
class ElasticsearchSearchEngine(OrigElasticsearchSearchEngine):
backend = ElasticsearchSearchBackend
query = ElasticsearchSearchQuery
...@@ -2,6 +2,8 @@ import os ...@@ -2,6 +2,8 @@ import os
import json import json
import sys import sys
from notesserver.settings.logger import get_logger_config
DEBUG = False DEBUG = False
TEMPLATE_DEBUG = False TEMPLATE_DEBUG = False
DISABLE_TOKEN_CHECK = False DISABLE_TOKEN_CHECK = False
...@@ -10,14 +12,22 @@ TIME_ZONE = 'UTC' ...@@ -10,14 +12,22 @@ TIME_ZONE = 'UTC'
# This value needs to be overriden in production. # 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' SECRET_KEY = '*^owi*4%!%9=#h@app!l^$jz8(c*q297^)4&4yn^#_m#fq=z#l'
ALLOWED_HOSTS = ['localhost', '*.edx.org']
# ID and Secret used for authenticating JWT Auth Tokens # ID and Secret used for authenticating JWT Auth Tokens
# should match those configured for `edx-notes` Client in EdX's /admin/oauth2/client/ # should match those configured for `edx-notes` Client in EdX's /admin/oauth2/client/
CLIENT_ID = 'edx-notes-id' CLIENT_ID = 'edx-notes-id'
CLIENT_SECRET = 'edx-notes-secret' CLIENT_SECRET = 'edx-notes-secret'
ELASTICSEARCH_URL = 'http://127.0.0.1:9200' ES_DISABLED = False
ELASTICSEARCH_INDEX = 'edx-notes' HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'notesserver.highlight.ElasticsearchSearchEngine',
'URL': 'http://127.0.0.1:9200/',
'INDEX_NAME': 'notes_index',
},
}
HAYSTACK_SIGNAL_PROCESSOR = 'haystack.signals.RealtimeSignalProcessor'
# Number of rows to return by default in result. # Number of rows to return by default in result.
RESULTS_DEFAULT_SIZE = 25 RESULTS_DEFAULT_SIZE = 25
...@@ -33,73 +43,21 @@ MIDDLEWARE_CLASSES = ( ...@@ -33,73 +43,21 @@ MIDDLEWARE_CLASSES = (
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
) )
INSTALLED_APPS = ( INSTALLED_APPS = [
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'rest_framework', 'rest_framework',
'rest_framework_swagger', 'rest_framework_swagger',
'corsheaders', 'corsheaders',
'haystack',
'notesapi', 'notesapi',
'notesapi.v1', 'notesapi.v1',
) ]
STATIC_URL = '/static/' STATIC_URL = '/static/'
WSGI_APPLICATION = 'notesserver.wsgi.application' WSGI_APPLICATION = 'notesserver.wsgi.application'
LOGGING = { LOGGING = get_logger_config()
'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
},
'notesapi.v1.permissions': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True
},
},
}
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
......
import annotator
from annotator import es
from .common import * from .common import *
from notesserver.settings.logger import get_logger_config
DEBUG = True DEBUG = True
ELASTICSEARCH_INDEX = 'edx-notes-dev' ES_INDEXES = {'default': 'notes_index_dev'}
############################################################################### DATABASES = {
# Override default annotator-store elasticsearch settings. 'default': {
############################################################################### 'ENGINE': 'django.db.backends.sqlite3',
es.host = ELASTICSEARCH_URL 'NAME': 'default.db',
es.index = ELASTICSEARCH_INDEX }
annotator.elasticsearch.RESULTS_MAX_SIZE = RESULTS_MAX_SIZE }
###############################################################################
LOGGING = get_logger_config(debug=DEBUG, dev_env=True, local_loglevel='DEBUG')
"""
Logging configuration
"""
import os
import platform
import sys
from logging.handlers import SysLogHandler
def get_logger_config(log_dir='/var/tmp',
logging_env="no_env",
edx_filename="edx.log",
dev_env=False,
debug=False,
local_loglevel='INFO',
service_variant='edx-notes-api'):
"""
Return the appropriate logging config dictionary. You should assign the
result of this to the LOGGING var in your settings.
If dev_env is set to true logging will not be done via local rsyslogd,
instead, application logs will be dropped in log_dir.
"edx_filename" is ignored unless dev_env is set to true since otherwise
logging is handled by rsyslogd.
"""
# Revert to INFO if an invalid string is passed in
if local_loglevel not in ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']:
local_loglevel = 'INFO'
hostname = platform.node().split(".")[0]
syslog_format = (
"[service_variant={service_variant}]"
"[%(name)s][env:{logging_env}] %(levelname)s "
"[{hostname} %(process)d] [%(filename)s:%(lineno)d] "
"- %(message)s"
).format(service_variant=service_variant, logging_env=logging_env, hostname=hostname)
if debug:
handlers = ['console']
else:
handlers = ['local']
logger_config = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s %(levelname)s %(process)d '
'[%(name)s] %(filename)s:%(lineno)d - %(message)s',
},
'syslog_format': {'format': syslog_format},
'raw': {'format': '%(message)s'},
},
'handlers': {
'console': {
'level': 'DEBUG' if debug else 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'standard',
'stream': sys.stdout,
},
},
'loggers': {
'django': {
'handlers': handlers,
'propagate': True,
'level': 'INFO'
},
"elasticsearch.trace": {
'handlers': handlers,
'level': 'DEBUG',
'propagate': False,
},
'': {
'handlers': handlers,
'level': 'DEBUG',
'propagate': False
},
}
}
if dev_env:
edx_file_loc = os.path.join(log_dir, edx_filename)
logger_config['handlers'].update({
'local': {
'class': 'logging.handlers.RotatingFileHandler',
'level': local_loglevel,
'formatter': 'standard',
'filename': edx_file_loc,
'maxBytes': 1024 * 1024 * 2,
'backupCount': 5,
},
})
else:
logger_config['handlers'].update({
'local': {
'level': local_loglevel,
'class': 'logging.handlers.SysLogHandler',
# Use a different address for Mac OS X
'address': '/var/run/syslog' if sys.platform == "darwin" else '/dev/log',
'formatter': 'syslog_format',
'facility': SysLogHandler.LOG_LOCAL0,
},
})
return logger_config
from annotator import es
import annotator
from .common import * from .common import *
DATABASES = { DATABASES = {
...@@ -9,15 +7,37 @@ DATABASES = { ...@@ -9,15 +7,37 @@ DATABASES = {
} }
TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' TEST_RUNNER = 'django_nose.NoseTestSuiteRunner'
DISABLE_TOKEN_CHECK = False
INSTALLED_APPS += ('django_nose',) INSTALLED_APPS += ('django_nose',)
ELASTICSEARCH_INDEX = 'edx-notes-test' HAYSTACK_CONNECTIONS = {
'default': {
'ENGINE': 'notesserver.highlight.ElasticsearchSearchEngine',
'URL': 'http://127.0.0.1:9200/',
'INDEX_NAME': 'notes_index_test',
},
}
############################################################################### LOGGING = {
# Override default annotator-store elasticsearch settings. 'version': 1,
############################################################################### 'disable_existing_loggers': False,
es.host = ELASTICSEARCH_URL 'handlers': {
es.index = ELASTICSEARCH_INDEX 'console': {
annotator.elasticsearch.RESULTS_MAX_SIZE = RESULTS_MAX_SIZE 'level': 'DEBUG',
############################################################################### 'class': 'logging.StreamHandler',
'stream': sys.stderr,
},
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'ERROR',
'propagate': False,
},
'elasticsearch.trace': {
'handlers': ['console'],
'level': 'ERROR',
'propagate': False
}
},
}
from .test import *
ES_DISABLED = True
HAYSTACK_CONNECTIONS = {}
INSTALLED_APPS.remove('haystack')
import yaml import yaml
import annotator
from annotator import es
from .common import * # pylint: disable=unused-wildcard-import, wildcard-import from .common import * # pylint: disable=unused-wildcard-import, wildcard-import
from path import path
from django.core.exceptions import ImproperlyConfigured
############################################################################### ###############################################################################
# Explicitly declare here in case someone changes common.py. # Explicitly declare here in case someone changes common.py.
...@@ -12,17 +12,32 @@ TEMPLATE_DEBUG = False ...@@ -12,17 +12,32 @@ TEMPLATE_DEBUG = False
DISABLE_TOKEN_CHECK = False DISABLE_TOKEN_CHECK = False
############################################################################### ###############################################################################
CONFIG_ROOT = os.environ.get('EDXNOTES_CONFIG_ROOT') EDXNOTES_CONFIG_ROOT = os.environ.get('EDXNOTES_CONFIG_ROOT')
if not EDXNOTES_CONFIG_ROOT:
raise ImproperlyConfigured("EDXNOTES_CONFIG_ROOT must be defined in the environment.")
CONFIG_ROOT = path(EDXNOTES_CONFIG_ROOT)
with open(CONFIG_ROOT / "edx-notes-api.yml") as yaml_file: with open(CONFIG_ROOT / "edx-notes-api.yml") as yaml_file:
config_from_yaml = yaml.load(yaml_file) config_from_yaml = yaml.load(yaml_file)
vars().update(config_from_yaml) vars().update(config_from_yaml)
############################################################################### #
# Override default annotator-store elasticsearch settings. # Support environment overrides for migrations
############################################################################### DB_OVERRIDES = dict(
es.host = ELASTICSEARCH_URL PASSWORD=environ.get('DB_MIGRATION_PASS', DATABASES['default']['PASSWORD']),
es.index = ELASTICSEARCH_INDEX ENGINE=environ.get('DB_MIGRATION_ENGINE', DATABASES['default']['ENGINE']),
annotator.elasticsearch.RESULTS_MAX_SIZE = RESULTS_MAX_SIZE USER=environ.get('DB_MIGRATION_USER', DATABASES['default']['USER']),
############################################################################### NAME=environ.get('DB_MIGRATION_NAME', DATABASES['default']['NAME']),
HOST=environ.get('DB_MIGRATION_HOST', DATABASES['default']['HOST']),
PORT=environ.get('DB_MIGRATION_PORT', DATABASES['default']['PORT']),
)
for override, value in DB_OVERRIDES.iteritems():
DATABASES['default'][override] = value
if ES_DISABLED:
HAYSTACK_CONNECTIONS = {}
INSTALLED_APPS.remove('haystack')
import datetime import datetime
import base64 from unittest import skipIf
from mock import patch, Mock from mock import patch, Mock
from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from elasticsearch.exceptions import TransportError from elasticsearch.exceptions import TransportError
class OperationalEndpointsTest(APITestCase): class OperationalEndpointsTest(APITestCase):
""" """
Tests for operational endpoints. Tests for operational endpoints.
...@@ -17,16 +19,27 @@ class OperationalEndpointsTest(APITestCase): ...@@ -17,16 +19,27 @@ class OperationalEndpointsTest(APITestCase):
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertEquals(response.data, {"OK": True}) self.assertEquals(response.data, {"OK": True})
@patch('annotator.elasticsearch.ElasticSearch.conn') @skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.")
def test_heartbeat_failure(self, mocked_conn): @patch('notesserver.views.get_es')
def test_heartbeat_failure_es(self, mocked_get_es):
""" """
Elasticsearch is not reachable. Elasticsearch is not reachable.
""" """
mocked_conn.ping.return_value = False mocked_get_es.return_value.ping.return_value = False
response = self.client.get(reverse('heartbeat')) response = self.client.get(reverse('heartbeat'))
self.assertEquals(response.status_code, 500) self.assertEquals(response.status_code, 500)
self.assertEquals(response.data, {"OK": False, "check": "es"}) self.assertEquals(response.data, {"OK": False, "check": "es"})
@patch("django.db.backends.utils.CursorWrapper")
def test_heartbeat_failure_db(self, mocked_cursor_wrapper):
"""
Database is not reachable.
"""
mocked_cursor_wrapper.side_effect = Exception
response = self.client.get(reverse('heartbeat'))
self.assertEquals(response.status_code, 500)
self.assertEquals(response.data, {"OK": False, "check": "db"})
def test_root(self): def test_root(self):
""" """
Test root endpoint. Test root endpoint.
...@@ -41,41 +54,67 @@ class OperationalEndpointsTest(APITestCase): ...@@ -41,41 +54,67 @@ class OperationalEndpointsTest(APITestCase):
} }
) )
class OperationalAuthEndpointsTest(APITestCase):
"""
Tests for operational authenticated endpoints.
"""
def test_selftest_status(self): def test_selftest_status(self):
""" """
Test status on authorization success. Test status success.
""" """
response = self.client.get(reverse('selftest')) response = self.client.get(reverse('selftest'))
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
@skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.")
@patch('notesserver.views.datetime', datetime=Mock(wraps=datetime.datetime)) @patch('notesserver.views.datetime', datetime=Mock(wraps=datetime.datetime))
@patch('annotator.elasticsearch.ElasticSearch.conn') @patch('notesserver.views.get_es')
def test_selftest_data(self, mocked_conn, mocked_datetime): def test_selftest_data(self, mocked_get_es, mocked_datetime):
""" """
Test returned data on success. Test returned data on success.
""" """
mocked_datetime.datetime.now.return_value = datetime.datetime(2014, 12, 11) mocked_datetime.datetime.now.return_value = datetime.datetime(2014, 12, 11)
mocked_conn.info.return_value = {} mocked_get_es.return_value.info.return_value = {}
response = self.client.get(reverse('selftest')) response = self.client.get(reverse('selftest'))
self.assertEquals(response.status_code, 200) self.assertEquals(response.status_code, 200)
self.assertEquals( self.assertEquals(
response.data, response.data,
{ {
"es": {}, "es": {},
"db": "OK",
"time_elapsed": 0.0
}
)
@patch('django.conf.settings.ES_DISABLED', True)
@patch('notesserver.views.datetime', datetime=Mock(wraps=datetime.datetime))
def test_selftest_data_es_disabled(self, mocked_datetime):
"""
Test returned data on success.
"""
mocked_datetime.datetime.now.return_value = datetime.datetime(2014, 12, 11)
response = self.client.get(reverse('selftest'))
self.assertEquals(response.status_code, 200)
self.assertEquals(
response.data,
{
"db": "OK",
"time_elapsed": 0.0 "time_elapsed": 0.0
} }
) )
@patch('annotator.elasticsearch.ElasticSearch.conn') @skipIf(settings.ES_DISABLED, "Do not test if Elasticsearch service is disabled.")
def test_selftest_failure(self, mocked_conn): @patch('notesserver.views.get_es')
def test_selftest_failure_es(self, mocked_get_es):
""" """
Elasticsearch is not reachable on selftest. Elasticsearch is not reachable on selftest.
""" """
mocked_conn.info.side_effect = TransportError() mocked_get_es.return_value.info.side_effect = TransportError()
response = self.client.get(reverse('selftest'))
self.assertEquals(response.status_code, 500)
self.assertIn('es_error', response.data)
@patch("django.db.backends.utils.CursorWrapper")
def test_selftest_failure_db(self, mocked_cursor_wrapper):
"""
Database is not reachable on selftest.
"""
mocked_cursor_wrapper.side_effect = Exception
response = self.client.get(reverse('selftest')) response = self.client.get(reverse('selftest'))
self.assertEquals(response.status_code, 500) self.assertEquals(response.status_code, 500)
self.assertIn('db_error', response.data)
import traceback import traceback
import datetime import datetime
from django.db import connection
from django.conf import settings
from rest_framework import status from rest_framework import status
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.decorators import api_view, permission_classes
from rest_framework.decorators import api_view, permission_classes, authentication_classes
from elasticsearch.exceptions import TransportError from elasticsearch.exceptions import TransportError
from annotator import es
if not settings.ES_DISABLED:
from haystack import connections
def get_es():
return connections['default'].get_backend().conn
@api_view(['GET']) @api_view(['GET'])
...@@ -26,13 +33,18 @@ def root(request): # pylint: disable=unused-argument ...@@ -26,13 +33,18 @@ def root(request): # pylint: disable=unused-argument
@permission_classes([AllowAny]) @permission_classes([AllowAny])
def heartbeat(request): # pylint: disable=unused-argument def heartbeat(request): # pylint: disable=unused-argument
""" """
ElasticSearch is reachable and ready to handle requests. ElasticSearch and database are reachable and ready to handle requests.
""" """
if es.conn.ping(): try:
return Response({"OK": True}) db_status()
else: except Exception:
return Response({"OK": False, "check": "db"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
if not settings.ES_DISABLED and not get_es().ping():
return Response({"OK": False, "check": "es"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) return Response({"OK": False, "check": "es"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
return Response({"OK": True})
@api_view(['GET']) @api_view(['GET'])
@permission_classes([AllowAny]) @permission_classes([AllowAny])
...@@ -41,18 +53,43 @@ def selftest(request): # pylint: disable=unused-argument ...@@ -41,18 +53,43 @@ def selftest(request): # pylint: disable=unused-argument
Manual test endpoint. Manual test endpoint.
""" """
start = datetime.datetime.now() start = datetime.datetime.now()
if not settings.ES_DISABLED:
try:
es_status = get_es().info()
except TransportError:
return Response(
{"es_error": traceback.format_exc()},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
try: try:
es_status = es.conn.info() db_status()
except TransportError: database = "OK"
except Exception:
return Response( return Response(
{"es_error": traceback.format_exc()}, {"db_error": traceback.format_exc()},
status=status.HTTP_500_INTERNAL_SERVER_ERROR status=status.HTTP_500_INTERNAL_SERVER_ERROR
) )
end = datetime.datetime.now() end = datetime.datetime.now()
delta = end - start delta = end - start
return Response({ response = {
"es": es_status, "db": database,
"time_elapsed": int(delta.total_seconds() * 1000) # In milliseconds. "time_elapsed": int(delta.total_seconds() * 1000) # In milliseconds.
}) }
if not settings.ES_DISABLED:
response['es'] = es_status
return Response(response)
def db_status():
"""
Return database status.
"""
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
Django==1.7.1 Django==1.7.1
requests==2.4.3 requests==2.4.3
djangorestframework==2.4.4 djangorestframework==3.0.2
django-rest-swagger==0.2.0 django-rest-swagger==0.2.0
elasticsearch==1.2.0 django-haystack==2.3.1
annotator==0.12.0 elasticsearch==0.4.5 # only 0.4 works for ES 0.90
django-cors-headers==0.13 django-cors-headers==0.13
PyJWT==0.3.0 PyJWT==0.3.0
MySQL-python==1.2.5 # GPL License
gunicorn==19.1.1 # MIT
path.py==3.0.1
python-dateutil==2.4.0
#!/bin/bash
# Installs Elasticsearch
#
# Requires ESVER environment variable to be set.
set -e
if [[ $ESVER == "-" ]];
then
exit 0
fi
echo "Installing ElasticSearch $ESVER" >&2
wget https://download.elasticsearch.org/elasticsearch/elasticsearch/elasticsearch-$ESVER.tar.gz
tar xzvf elasticsearch-$ESVER.tar.gz
#!/bin/bash
# Runs Elasticsearch
#
# Requires ESVER environment variable to be set.
# cwd is the git repository root.
set -e
if [[ $ESVER == "-" ]];
then
exit 0
fi
echo "Starting ElasticSearch $ESVER" >&2
pushd elasticsearch-$ESVER
# Elasticsearch 0.90 daemonizes automatically, but 1.0+ requires
# a -d argument.
if [[ $ESVER == 0* ]];
then
./bin/elasticsearch
else
echo "launching with -d option." >&2
./bin/elasticsearch -d
pip install elasticsearch==1.3.0
fi
popd
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