improve api
authorymh <ymh.work@gmail.com>
Wed, 14 Jun 2017 15:17:51 +0200
changeset 31 63be3ce389f7
parent 30 4d93f4ed95bc
child 32 52f7a51ef948
improve api
src/.keepme
src/.pylintrc
src/README
src/irinotes/settings.py
src/irinotes/urls.py
src/notes/api/__init__.py
src/notes/api/permissions/__init__.py
src/notes/api/permissions/core.py
src/notes/api/serializers/__init__.py
src/notes/api/serializers/core.py
src/notes/api/urls.py
src/notes/api/views/__init__.py
src/notes/api/views/core.py
src/notes/locale/en/LC_MESSAGES/django.mo
src/notes/locale/en/LC_MESSAGES/django.po
src/notes/locale/fr/LC_MESSAGES/django.mo
src/notes/locale/fr/LC_MESSAGES/django.po
src/notes/migrations/0001_initial.py
src/notes/models/category.py
src/notes/models/core.py
src/notes/tests.py
src/notes/tests/__init__.py
src/notes/tests/api/__init__.py
src/notes/tests/api/session.py
src/notes/urls.py
src/requirements/base.txt
src/setup.py
--- a/src/.pylintrc	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/.pylintrc	Wed Jun 14 15:17:51 2017 +0200
@@ -136,7 +136,7 @@
 function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$
 
 # Good variable names which should always be accepted, separated by a comma
-good-names=i,j,k,ex,Run,_
+good-names=i,j,k,ex,Run,_,logger
 
 # Include a hint for the correct naming format with invalid-name
 include-naming-hint=no
--- a/src/README	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/README	Wed Jun 14 15:17:51 2017 +0200
@@ -12,7 +12,9 @@
 $ cp .env.tmpl .env
 $ vi .env
 $ mkvirtualenv irinotes
-$ pip install requirements/dev.txt
+$ cd requirements
+$ pip install dev.txt
+$ cd ..
 $ python manage.py migrate
 $ python manage.py collectstatic
 $ python manage.py createsuperuser
--- a/src/irinotes/settings.py	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/irinotes/settings.py	Wed Jun 14 15:17:51 2017 +0200
@@ -48,6 +48,8 @@
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
+    'django_extensions',
+    'rest_framework',
     'colorful',
     'concurrency',
     'notes'
@@ -180,10 +182,31 @@
             'level': LOG_LEVEL,
             'propagate': True,
         },
+        'django.db.backends': {
+            'handlers': ['file'],
+            'level': LOG_LEVEL,
+            'propagate': True,
+        },
         'irinotes': {
             'handlers': ['file'],
             'level': LOG_LEVEL,
             'propagate': True,
         },
+        'notes': {
+            'handlers': ['file'],
+            'level': LOG_LEVEL,
+            'propagate': True,
+        },
     }
 }
+
+# Rest Framework configuration
+
+REST_FRAMEWORK = {
+    # Use Django's standard `django.contrib.auth` permissions,
+    # or allow read-only access for unauthenticated users.
+    'DEFAULT_PERMISSION_CLASSES': [
+        'rest_framework.permissions.IsAuthenticated'
+    ],
+    'TEST_REQUEST_DEFAULT_FORMAT': 'json'
+}
--- a/src/irinotes/urls.py	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/irinotes/urls.py	Wed Jun 14 15:17:51 2017 +0200
@@ -13,9 +13,10 @@
     1. Import the include() function: from django.conf.urls import url, include
     2. Add a URL to urlpatterns:  url(r'^blog/', include('blog.urls'))
 """
-from django.conf.urls import url
+from django.conf.urls import url, include
 from django.contrib import admin
 
 urlpatterns = [
     url(r'^admin/', admin.site.urls),
+    url(r'^notes/', include('notes.urls')),
 ]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/api/permissions/__init__.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,6 @@
+"""
+Permissions classes fro notes
+"""
+from .core import SessionPermission, NotePermission
+
+__all__ = ["SessionPermission", "NotePermission"]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/api/permissions/core.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,38 @@
+"""
+Permissions for core objects
+"""
+import logging
+
+from rest_framework.permissions import IsAuthenticated
+
+from notes.models import Session
+
+logger = logging.getLogger(__name__)
+
+class SessionPermission(IsAuthenticated):
+    """
+    Pemissions for sessions
+    """
+
+    def has_object_permission(self, request, view, obj):
+        return request.user == obj.owner
+
+
+class NotePermission(IsAuthenticated):
+    """
+    Permissions for notes
+    """
+
+    def has_permission(self, request, view):
+        """
+        Return `True` if permission is granted, `False` otherwise.
+        """
+        is_authenticated = super().has_permission(request, view)
+        if not is_authenticated:
+            return False
+        session_ext_id = view.kwargs.get('session_ext_id')
+        if is_authenticated and session_ext_id:
+            return Session.objects.filter(ext_id=session_ext_id, owner=request.user).exists()
+        else:
+            return True
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/api/serializers/core.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,55 @@
+"""
+Serializers for model core classes
+"""
+from rest_framework import serializers
+
+from notes.models import Note, Session
+
+
+class DetailNoteSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Note
+        fields = (
+            'ext_id', 'version', 'created', 'updated',
+            'plain', 'html', 'raw',
+            'categorization', 'margin_note', 'tc_start', 'tc_end'
+        )
+        read_only_fields = ('ext_id', 'version', 'created', 'updated')
+
+
+class ListNoteSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Note
+        fields = (
+            'ext_id', 'tc_start', 'tc_end'
+        )
+        read_only_fields = ('ext_id', )
+
+
+class ListSessionSerializer(serializers.ModelSerializer):
+
+    owner = serializers.SlugRelatedField(
+        read_only=True, slug_field='username', default=serializers.CurrentUserDefault())
+
+    class Meta:
+        model = Session
+        fields = (
+            'ext_id', 'version', 'created', 'updated',
+            'owner', 'title', 'description', 'protocol'
+        )
+        read_only_fields = ('ext_id', 'version', 'created', 'updated', 'owner')
+
+
+class DetailSessionSerializer(serializers.ModelSerializer):
+
+    owner = serializers.SlugRelatedField(read_only=True, slug_field='username')
+    notes = DetailNoteSerializer(many=True, read_only=True)
+
+    class Meta:
+        model = Session
+        fields = (
+            'ext_id', 'version', 'created', 'updated',
+            'owner', 'title', 'description', 'protocol',
+            'notes'
+        )
+        read_only_fields = ('ext_id', 'version', 'created', 'updated', 'owner')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/api/urls.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,16 @@
+from django.conf.urls import url, include
+from rest_framework_nested import routers
+from .views import SessionViewSet, NoteViewSet
+
+router = routers.SimpleRouter()
+router.register(r'sessions', SessionViewSet, base_name='session')
+
+session_router = routers.NestedSimpleRouter(router, r'sessions', lookup='session')
+session_router.register(r'notes', NoteViewSet, base_name='notes')
+
+# Wire up our API using automatic URL routing.
+# Additionally, we include login URLs for the browsable API.
+urlpatterns = [
+    url(r'^', include(router.urls)),
+    url(r'^', include(session_router.urls)),
+]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/api/views/__init__.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,3 @@
+from .core import SessionViewSet, NoteViewSet
+
+__all__ = ['SessionViewSet', 'NoteViewSet']
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/api/views/core.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,52 @@
+import logging
+
+from notes.models import Note, Session
+from rest_framework import viewsets
+
+from ..permissions import NotePermission, SessionPermission
+from ..serializers.core import (DetailNoteSerializer, DetailSessionSerializer,
+                                ListNoteSerializer, ListSessionSerializer)
+
+logger = logging.getLogger(__name__)
+
+
+class SessionViewSet(viewsets.ModelViewSet):
+    """
+    API endpoint that allow sessions ro be viewed or edited
+    """
+    serializer_class = ListSessionSerializer
+    lookup_field = 'ext_id'
+
+    serializers = {
+        'list': ListSessionSerializer,
+        'retrieve':  DetailSessionSerializer,
+    }
+
+    permission_classes = (SessionPermission,)
+
+    def get_serializer_class(self):
+        return self.serializers.get(self.action, ListSessionSerializer)
+
+    def get_queryset(self):
+        return Session.objects.filter(owner=self.request.user)
+
+
+class NoteViewSet(viewsets.ModelViewSet):
+
+    serializers = {
+        'list': ListNoteSerializer,
+        'retrieve': DetailNoteSerializer,
+        'create': DetailNoteSerializer,
+        'update': DetailNoteSerializer,
+    }
+    lookup_field = 'ext_id'
+
+    permission_classes = (NotePermission,)
+
+    def get_serializer_class(self):
+        return self.serializers.get(self.action, ListNoteSerializer)
+
+    def get_queryset(self):
+        return Note.objects.filter(
+            session__ext_id=self.kwargs['session_ext_id'],
+            session__owner=self.request.user)
Binary file src/notes/locale/en/LC_MESSAGES/django.mo has changed
--- a/src/notes/locale/en/LC_MESSAGES/django.po	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/notes/locale/en/LC_MESSAGES/django.po	Wed Jun 14 15:17:51 2017 +0200
@@ -137,15 +137,15 @@
 msgstr "session"
 
 #: models/core.py:33
-msgid "Note|text_plain"
+msgid "Note|plain"
 msgstr "text plain"
 
 #: models/core.py:34
-msgid "Note|text_html"
+msgid "Note|html"
 msgstr "text html"
 
 #: models/core.py:35
-msgid "Note|text_raw"
+msgid "Note|raw"
 msgstr "text raw"
 
 #: models/core.py:36
Binary file src/notes/locale/fr/LC_MESSAGES/django.mo has changed
--- a/src/notes/locale/fr/LC_MESSAGES/django.po	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/notes/locale/fr/LC_MESSAGES/django.po	Wed Jun 14 15:17:51 2017 +0200
@@ -138,15 +138,15 @@
 msgstr "session"
 
 #: models/core.py:33
-msgid "Note|text_plain"
+msgid "Note|plain"
 msgstr "texte seul"
 
 #: models/core.py:34
-msgid "Note|text_html"
+msgid "Note|html"
 msgstr "texte html"
 
 #: models/core.py:35
-msgid "Note|text_raw"
+msgid "Note|raw"
 msgstr "texte brut"
 
 #: models/core.py:36
--- a/src/notes/migrations/0001_initial.py	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/notes/migrations/0001_initial.py	Wed Jun 14 15:17:51 2017 +0200
@@ -1,7 +1,8 @@
 # -*- coding: utf-8 -*-
-# Generated by Django 1.11.2 on 2017-06-08 15:10
+# Generated by Django 1.11.2 on 2017-06-13 11:53
 from __future__ import unicode_literals
 
+import colorful.fields
 import concurrency.fields
 from django.conf import settings
 import django.contrib.auth.models
@@ -47,6 +48,20 @@
             ],
         ),
         migrations.CreateModel(
+            name='Category',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=255, verbose_name='Category|title')),
+                ('color', colorful.fields.RGBColorField(verbose_name='Category|color')),
+                ('need_comment', models.BooleanField(default=False, verbose_name='Category|need_comment')),
+                ('description', models.TextField(blank=True, null=True, verbose_name='Category|description')),
+            ],
+            options={
+                'verbose_name': 'Category',
+                'verbose_name_plural': 'Categories',
+            },
+        ),
+        migrations.CreateModel(
             name='GroupProfile',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
@@ -66,11 +81,11 @@
                 ('updated', models.DateTimeField(auto_now=True, verbose_name='Model|updated')),
                 ('ext_id', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='Model|ext_id')),
                 ('version', concurrency.fields.AutoIncVersionField(default=1, help_text='record revision number', verbose_name='Model|version')),
-                ('tc_start', models.DateTimeField()),
-                ('tc_end', models.DateTimeField()),
-                ('text_plain', models.TextField(blank=True, null=True, verbose_name='Note|text_plain')),
-                ('text_html', models.TextField(blank=True, null=True, verbose_name='Note|text_html')),
-                ('text_raw', models.TextField(blank=True, null=True, verbose_name='Note|text_raw')),
+                ('tc_start', models.DateTimeField(verbose_name='Note|tc_start')),
+                ('tc_end', models.DateTimeField(verbose_name='Note|tc_end')),
+                ('plain', models.TextField(blank=True, null=True, verbose_name='Note|plain')),
+                ('html', models.TextField(blank=True, null=True, verbose_name='Note|html')),
+                ('raw', models.TextField(blank=True, null=True, verbose_name='Note|raw')),
                 ('margin_note', models.TextField(blank=True, null=True, verbose_name='Note|margin_note')),
                 ('categorization', models.TextField(blank=True, null=True, verbose_name='Note|categorization')),
             ],
@@ -81,6 +96,22 @@
             },
         ),
         migrations.CreateModel(
+            name='Protocol',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateTimeField(auto_now_add=True, verbose_name='Model|created')),
+                ('updated', models.DateTimeField(auto_now=True, verbose_name='Model|updated')),
+                ('ext_id', models.UUIDField(default=uuid.uuid4, unique=True, verbose_name='Model|ext_id')),
+                ('version', concurrency.fields.AutoIncVersionField(default=1, help_text='record revision number', verbose_name='Model|version')),
+                ('title', models.CharField(max_length=255, verbose_name='Protocol|title')),
+                ('group_profile', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='notes.GroupProfile')),
+            ],
+            options={
+                'verbose_name': 'Protocol',
+                'verbose_name_plural': 'Protocols',
+            },
+        ),
+        migrations.CreateModel(
             name='Session',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
@@ -112,6 +143,11 @@
         migrations.AddField(
             model_name='note',
             name='session',
-            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notes.Session', verbose_name='Note|session'),
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='notes.Session', verbose_name='Note|session'),
+        ),
+        migrations.AddField(
+            model_name='category',
+            name='protocol',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='categories', to='notes.Protocol', verbose_name='Category|protocol'),
         ),
     ]
--- a/src/notes/models/category.py	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/notes/models/category.py	Wed Jun 14 15:17:51 2017 +0200
@@ -11,24 +11,33 @@
 
 class Protocol(Model):
     title = models.CharField(max_length=255, verbose_name=_('Protocol|title'))
-    group_profile = models.OneToOneField(GroupProfile, on_delete=models.CASCADE)
+    group_profile = models.OneToOneField(
+        GroupProfile, on_delete=models.CASCADE)
+
     class Meta:
         verbose_name = _('Protocol')
         verbose_name_plural = _('Protocols')
 
 
-
 class Category(models.Model):
     title = models.CharField(max_length=255, verbose_name=_('Category|title'))
     color = RGBColorField(verbose_name=_('Category|color'))
-    need_comment = models.BooleanField(default=False, verbose_name=_('Category|need_comment'))
-    description = models.TextField(null=True, blank=True, verbose_name=_('Category|description'))
+    need_comment = models.BooleanField(
+        default=False,
+        verbose_name=_('Category|need_comment')
+    )
+    description = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Category|description')
+    )
     protocol = models.ForeignKey(
         Protocol,
         verbose_name=_('Category|protocol'),
         related_name='categories',
         on_delete=models.CASCADE
     )
+
     class Meta:
         verbose_name = _('Category')
         verbose_name_plural = _('Categories')
--- a/src/notes/models/core.py	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/notes/models/core.py	Wed Jun 14 15:17:51 2017 +0200
@@ -17,9 +17,22 @@
         settings.AUTH_USER_MODEL,
         on_delete=models.CASCADE,
     )
-    title = models.TextField(null=True, blank=True, verbose_name=_('Session|title'))
-    description = models.TextField(null=True, blank=True, verbose_name=_('Session|description'))
-    protocol = models.TextField(null=True, blank=True, verbose_name=_('Session|protocol'))
+    title = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Session|title')
+    )
+    description = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Session|description')
+    )
+    protocol = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Session|protocol')
+    )
+
 
 class Note(Model):
     class Meta:
@@ -29,10 +42,34 @@
 
     tc_start = models.DateTimeField(verbose_name=_('Note|tc_start'))
     tc_end = models.DateTimeField(verbose_name=_('Note|tc_end'))
-    session = models.ForeignKey(Session, on_delete=models.CASCADE, verbose_name=_('Note|session'))
-    text_plain = models.TextField(null=True, blank=True, verbose_name=_('Note|text_plain'))
-    text_html = models.TextField(null=True, blank=True, verbose_name=_('Note|text_html'))
-    text_raw = models.TextField(null=True, blank=True, verbose_name=_('Note|text_raw'))
-    margin_note = models.TextField(null=True, blank=True, verbose_name=_('Note|margin_note'))
-    categorization = models.TextField(null=True, blank=True, verbose_name=_('Note|categorization'))
-
+    session = models.ForeignKey(
+        Session,
+        on_delete=models.CASCADE,
+        related_name='notes',
+        verbose_name=_('Note|session')
+    )
+    plain = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Note|plain')
+    )
+    html = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Note|html')
+    )
+    raw = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Note|raw')
+    )
+    margin_note = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Note|margin_note')
+    )
+    categorization = models.TextField(
+        null=True,
+        blank=True,
+        verbose_name=_('Note|categorization')
+    )
--- a/src/notes/tests.py	Wed Jun 14 12:28:09 2017 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-from django.test import TestCase
-
-# Create your tests here.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/tests/__init__.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,1 @@
+from .api import SessionApiTests
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/tests/api/__init__.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,3 @@
+from .session import SessionApiTests
+
+__all__ = 'SessionApiTests'
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/tests/api/session.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,146 @@
+"""
+Tests the core api for sessions
+"""
+import logging
+
+from django.contrib.auth import get_user_model
+from django.urls import reverse
+from django.utils import timezone
+from rest_framework import status
+from rest_framework.test import APITransactionTestCase
+
+from notes.models import Session, Note
+
+logger = logging.getLogger(__name__)
+
+class SessionApiTests(APITransactionTestCase):
+
+    def setUp(self):
+        User = get_user_model()
+        user1 = User.objects.create_user(
+            username='test_user1',
+            email='test_user@emial.com',
+            password='top_secret'
+        )
+        user2 = User.objects.create_user(
+            username='test_user2',
+            email='test_user@emial.com',
+            password='top_secret'
+        )
+        user3 = User.objects.create_user(
+            username='test_user3',
+            email='test_user@emial.com',
+            password='top_secret'
+        )
+
+        self.session1 = Session.objects.create(
+            title="a new session 1",
+            description="Description 1",
+            protocol="[]",
+            owner=user1
+        )
+
+        self.session2 = Session.objects.create(
+            title="a new session 2",
+            description="Description 2",
+            protocol="[]",
+            owner=user2
+        )
+
+        Session.objects.create(
+            title="a new session 3",
+            description="Description 3",
+            protocol="[]",
+            owner=user3
+        )
+
+        Note.objects.create(
+            tc_start=timezone.now(),
+            tc_end=timezone.now(),
+            session=self.session1,
+            plain="example note 1",
+            html="<i>example note 1</i>",
+            raw="<i>example note 1</i>",
+            margin_note="margin note 1",
+            categorization="[]"
+        )
+
+        Note.objects.create(
+            tc_start=timezone.now(),
+            tc_end=timezone.now(),
+            session=self.session2,
+            plain="example note 2",
+            html="<i>example note</i>",
+            raw="<i>example note</i>",
+            margin_note="margin note",
+            categorization="[]"
+        )
+
+
+    def test_list_session_no_user(self):
+        url = reverse('notes_api:session-list')
+        response = self.client.post(url)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+
+    def test_list_session(self):
+        url = reverse('notes_api:session-list')
+        self.client.login(username='test_user1', password='top_secret')
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        json = response.json()
+        self.assertEqual(len(json), 1, "must have one session")
+        for session in json:
+            self.assertEqual(session['owner'], 'test_user1')
+
+
+    def test_create_session_no_user(self):
+        url = reverse('notes_api:session-list')
+        response = self.client.post(url, {
+            'title': "a new session",
+            'description': "description of the session",
+            'protocol': "[]"
+        }, format='json')
+
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+
+    def test_create_session(self):
+        url = reverse('notes_api:session-list')
+        self.client.login(username='test_user1', password='top_secret')
+        response = self.client.post(url, {
+            'title': "a new session",
+            'description': "description of the session",
+            'protocol': "[]"
+        }, format='json')
+
+
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        json = response.json()
+        self.assertIn('ext_id', json)
+
+    def test_detail_session(self):
+        url = reverse('notes_api:session-detail', kwargs={'ext_id':str(self.session1.ext_id)})
+        self.client.login(username='test_user1', password='top_secret')
+        response = self.client.get(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+    def test_list_notes(self):
+        url = reverse('notes_api:notes-list', kwargs={'session_ext_id':str(self.session1.ext_id)})
+        self.client.login(username='test_user1', password='top_secret')
+        response = self.client.get(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+    def test_detail_session_bad(self):
+        url = reverse('notes_api:session-detail', kwargs={'ext_id':str(self.session2.ext_id)})
+        self.client.login(username='test_user1', password='top_secret')
+        response = self.client.get(url, format='json')
+        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
+
+    def test_list_notes_bad(self):
+        url = reverse('notes_api:notes-list', kwargs={'session_ext_id':str(self.session2.ext_id)})
+        logger.debug("URL: %s", url)
+        self.client.login(username='test_user1', password='top_secret')
+        response = self.client.get(url, format='json')
+        logger.debug(response.json())
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/notes/urls.py	Wed Jun 14 15:17:51 2017 +0200
@@ -0,0 +1,8 @@
+from django.conf.urls import url, include
+
+from .api import urls as api_urls
+
+urlpatterns = [
+    url(r'^api/auth/', include('rest_framework.urls', namespace='rest_framework')),
+    url(r'^api/', include(api_urls, namespace='notes_api')),
+]
--- a/src/requirements/base.txt	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/requirements/base.txt	Wed Jun 14 15:17:51 2017 +0200
@@ -2,9 +2,11 @@
 Django==1.11.2
 django-colorful==1.2
 django-concurrency==1.3.2
+django-filter==1.0.4
 django-guardian==1.4.8
 djangorestframework==3.6.3
 irinotes==0.0.1
+Markdown==2.6.8
 python-decouple==3.0
 pytz==2017.2
 six==1.10.0
--- a/src/setup.py	Wed Jun 14 12:28:09 2017 +0200
+++ b/src/setup.py	Wed Jun 14 15:17:51 2017 +0200
@@ -143,7 +143,9 @@
             "djangorestframework >= 3.6",
             "django-guardian >= 1.4",
             "django-colorful",
-            "django-concurrency"
+            "django-concurrency",
+            "django-filter",
+            "markdown"
         ],
     )