diff --git a/.gitignore b/.gitignore
index ec6afcdea4ec7f7f9c46ca95a10a3b1ef393f605..f82852adf4dc0b2a50b95ffd691b04be57513447 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@ __pycache__/
 .env
 .cache/
 *.egg-info/
+.coverage
+.pytest_cache/
diff --git a/Makefile b/Makefile
index 9c9fdd5553ad8210824832662fd1c715ba53eb85..1004b1df8a96fec016cc07c582827b0c8dc88eb0 100644
--- a/Makefile
+++ b/Makefile
@@ -2,11 +2,14 @@ init-env:
 	python3 -m venv .env
 
 install:
-	pip install -r requirements.txt
+	pip install --upgrade -r requirements.txt
 	pip install -e .
 
 run:
-	SECRET_KEY=local FLASK_DEBUG=1 FLASK_APP=./openlobby/server.py flask run -p 8010
+	DEBUG=1 python manage.py runserver 8010
+
+migrate:
+	DEBUG=1 python manage.py migrate
 
 build:
 	docker build -t openlobby/openlobby-server:latest .
diff --git a/README.md b/README.md
index 7bd58cfcf606dd5f74ff130a4af2234c97e9de56..cd0bddfd4bd26efcae83ea55387bab206fa5eaf4 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,9 @@ prepared Elasticsearch Docker container with Czech support at
 ## Configuration
 
 Configuration is done by environment variables:
- - `SECRET_KEY` - long random secret string (required)
+ - `DEBUG` - Set to any value to turn on debug mode. Don't use in production!
+ - `SECRET_KEY` - long random secret string (required if not in debug mode)
+ - `DATABASE_DSN` - DSN of PostgreSQL database (default: `postgresql://db:db@localhost:5432/openlobby`)
  - `ELASTICSEARCH_DSN` - DSN of Elasticsearch cluster (default: `http://localhost:9200`)
  - `SITE_NAME` - site name for OpenID authentication (default: `Open Lobby`)
  - `ES_INDEX` - Elasticsearch index (default: `openlobby`)
@@ -44,12 +46,13 @@ You need to have Python 3 installed. Clone this repository and run:
 1. `make init-env` - prepares Python virtualenv in dir `.env`
 2. `source .env/bin/activate` - activates virtualenv
 3. `make install` - installs requirements and server in development mode
-4. `make run` - runs development server on port `8010`
+4. `make migrate` - runs database migrations
+5. `make run` - runs development server on port `8010`
 
 Now you can use GraphQL API endpoint and GraphiQL web interface at
 `http://localhost:8010/graphql`
 
-Next time you can just do steps 2 and 4.
+Next time you can just do steps 2 and 5.
 
 Development server assumes that you have
 [openlobby/openlobby-es-czech](https://github.com/openlobby/openlobby-es-czech).
diff --git a/manage.py b/manage.py
new file mode 100755
index 0000000000000000000000000000000000000000..de2d50d9e35c30f66f7de58d9efb1b4f2faa96e0
--- /dev/null
+++ b/manage.py
@@ -0,0 +1,22 @@
+#!/usr/bin/env python
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openlobby.settings")
+    try:
+        from django.core.management import execute_from_command_line
+    except ImportError:
+        # The above import may fail for some other reason. Ensure that the
+        # issue is really that Django is missing to avoid masking other
+        # exceptions on Python 2.
+        try:
+            import django
+        except ImportError:
+            raise ImportError(
+                "Couldn't import Django. Are you sure it's installed and "
+                "available on your PYTHONPATH environment variable? Did you "
+                "forget to activate a virtual environment?"
+            )
+        raise
+    execute_from_command_line(sys.argv)
diff --git a/openlobby/auth.py b/openlobby/auth.py
deleted file mode 100644
index 48d2243a11495f6cf5563dcd755d71719ea7507c..0000000000000000000000000000000000000000
--- a/openlobby/auth.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import json
-import jwt
-import re
-import time
-from flask import g, request
-from flask_graphql import GraphQLView
-
-from .settings import (
-    SECRET_KEY,
-    JWT_ALGORITHM,
-    LOGIN_ATTEMPT_EXPIRATION,
-    SESSION_EXPIRATION,
-)
-
-
-def get_login_attempt_expiration_time():
-    return int(time.time() + LOGIN_ATTEMPT_EXPIRATION)
-
-
-def get_session_expiration_time():
-    return int(time.time() + SESSION_EXPIRATION)
-
-
-def create_access_token(session_id, expiration):
-    payload = {
-        'sub': session_id,
-        'exp': expiration,
-    }
-    token = jwt.encode(payload, SECRET_KEY, algorithm=JWT_ALGORITHM)
-    return token.decode('utf-8')
-
-
-def parse_access_token(token):
-    payload = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM])
-    return payload['sub']
-
-
-def graphql_error_response(message, code=400):
-    error = {'message': message}
-    return json.dumps({'errors': [error]}), code, {'Content-Type': 'application/json'}
-
-
-class AuthGraphQLView(GraphQLView):
-    """
-    GraphQLView which sets session_id into 'g' if authorization token is
-    provided in Authorization header.
-    """
-
-    def dispatch_request(self):
-        session_id = None
-        auth_header = request.headers.get('Authorization')
-        if auth_header is not None:
-            m = re.match(r'Bearer (?P<token>.+)', auth_header)
-            if m:
-                token = m.group('token')
-            else:
-                return graphql_error_response('Wrong Authorization header. Expected: "Bearer <token>"')
-
-            try:
-                session_id = parse_access_token(token)
-            except jwt.InvalidTokenError:
-                session_id = None
-            except Exception:
-                return graphql_error_response('Wrong Authorization token.', 401)
-
-        g.session_id = session_id
-        return super(AuthGraphQLView, self).dispatch_request()
diff --git a/openlobby/core/__init__.py b/openlobby/core/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/openlobby/core/admin.py b/openlobby/core/admin.py
new file mode 100644
index 0000000000000000000000000000000000000000..731ffd5d4d9c5501f8052f597d4329b143562a83
--- /dev/null
+++ b/openlobby/core/admin.py
@@ -0,0 +1,14 @@
+from django.contrib import admin
+from django.contrib.auth.admin import UserAdmin
+
+from .models import OpenIdClient, User
+
+
+@admin.register(OpenIdClient)
+class OpenIdClientAdmin(admin.ModelAdmin):
+    pass
+
+
+@admin.register(User)
+class MyUserAdmin(UserAdmin):
+    pass
diff --git a/openlobby/core/api/__init__.py b/openlobby/core/api/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/openlobby/core/api/mutations.py b/openlobby/core/api/mutations.py
new file mode 100644
index 0000000000000000000000000000000000000000..51bfb8563e0bc2fd969dc19ce651b97ab997a1b0
--- /dev/null
+++ b/openlobby/core/api/mutations.py
@@ -0,0 +1,140 @@
+import graphene
+from graphene import relay
+from graphene.types.datetime import DateTime
+from graphql_relay import from_global_id
+from oic.oic import rndstr
+
+from ..models import OpenIdClient, LoginAttempt, Report
+from ..openid import (
+    discover_issuer,
+    init_client_for_shortcut,
+    register_client,
+    get_authorization_url,
+)
+from . import types
+from .sanitizers import strip_all_tags
+
+
+STATE_LENGTH = 48
+
+
+class Login(relay.ClientIDMutation):
+
+    class Input:
+        openid_uid = graphene.String(required=True)
+        redirect_uri = graphene.String(required=True)
+
+    authorization_url = graphene.String()
+
+    @classmethod
+    def mutate_and_get_payload(cls, root, info, **input):
+        openid_uid = input['openid_uid']
+        app_redirect_uri = input['redirect_uri']
+
+        # prepare OpenID client
+        issuer = discover_issuer(openid_uid)
+        try:
+            openid_client_obj = OpenIdClient.objects.get(issuer=issuer)
+            client = init_client_for_shortcut(openid_client_obj)
+        except OpenIdClient.DoesNotExist:
+            client = register_client(issuer)
+            openid_client_obj = OpenIdClient.objects.create(
+                name=issuer,
+                issuer=issuer,
+                client_id=client.client_id,
+                client_secret=client.client_secret,
+            )
+
+        # prepare login attempt details
+        state = rndstr(STATE_LENGTH)
+
+        # save login attempt
+        LoginAttempt.objects.create(state=state, openid_client=openid_client_obj,
+            app_redirect_uri=app_redirect_uri, openid_uid=openid_uid)
+
+        # get OpenID authorization url
+        authorization_url = get_authorization_url(client, state)
+
+        return Login(authorization_url=authorization_url)
+
+
+class LoginByShortcut(relay.ClientIDMutation):
+
+    class Input:
+        shortcut_id = relay.GlobalID(required=True)
+        redirect_uri = graphene.String(required=True)
+
+    authorization_url = graphene.String()
+
+    @classmethod
+    def mutate_and_get_payload(cls, root, info, **input):
+        shortcut_id = input['shortcut_id']
+        app_redirect_uri = input['redirect_uri']
+
+        # prepare OpenID client
+        type, id = from_global_id(shortcut_id)
+        openid_client_obj = OpenIdClient.objects.get(id=id)
+        client = init_client_for_shortcut(openid_client_obj)
+
+        # prepare login attempt
+        state = rndstr(STATE_LENGTH)
+
+        # save login attempt
+        LoginAttempt.objects.create(state=state, openid_client=openid_client_obj,
+            app_redirect_uri=app_redirect_uri)
+
+        # get OpenID authorization url
+        authorization_url = get_authorization_url(client, state)
+
+        return LoginByShortcut(authorization_url=authorization_url)
+
+
+class Logout(relay.ClientIDMutation):
+    success = graphene.Boolean()
+
+    @classmethod
+    def mutate_and_get_payload(cls, root, info, **input):
+        raise NotImplementedError()
+        return Logout(success=True)
+
+
+class NewReport(relay.ClientIDMutation):
+
+    class Input:
+        title = graphene.String(required=True)
+        body = graphene.String(required=True)
+        received_benefit = graphene.String()
+        provided_benefit = graphene.String()
+        our_participants = graphene.String()
+        other_participants = graphene.String()
+        date = DateTime(required=True)
+
+    report = graphene.Field(types.Report)
+
+    @classmethod
+    def mutate_and_get_payload(cls, root, info, **input):
+        if not info.context.user.is_authenticated:
+            raise Exception('User must be logged in to perform this mutation.')
+
+        author = info.context.user
+
+        report = Report.objects.create(
+            author=author,
+            date=input.get('date'),
+            title=strip_all_tags(input.get('title', '')),
+            body=strip_all_tags(input.get('body', '')),
+            received_benefit=strip_all_tags(input.get('received_benefit', '')),
+            provided_benefit=strip_all_tags(input.get('provided_benefit', '')),
+            our_participants=strip_all_tags(input.get('our_participants', '')),
+            other_participants=strip_all_tags(input.get('other_participants', '')),
+        )
+
+        return NewReport(report=types.Report.from_db(report))
+
+
+class Mutation:
+    login = Login.Field()
+    login_by_shortcut = LoginByShortcut.Field()
+    # TODO
+    # logout = Logout.Field()
+    new_report = NewReport.Field()
diff --git a/openlobby/paginator.py b/openlobby/core/api/paginator.py
similarity index 87%
rename from openlobby/paginator.py
rename to openlobby/core/api/paginator.py
index d94f9e16a08225f94e251376173affab553093cd..ea7b05ae39935f072045bd4cb1ae31bbd8a12eff 100644
--- a/openlobby/paginator.py
+++ b/openlobby/core/api/paginator.py
@@ -5,6 +5,10 @@ from graphene.relay import PageInfo
 PER_PAGE = 10
 
 
+class MissingBeforeValueError(Exception):
+    pass
+
+
 def encode_cursor(num):
     return base64.b64encode(str(num).encode('utf-8')).decode('utf-8')
 
@@ -27,8 +31,10 @@ class Paginator:
             slice_to = slice_from + first
 
         elif last is not None:
-            if before is not None:
-                slice_to = decode_cursor(before) - 1
+            if before is None:
+                raise MissingBeforeValueError('Pagination "last" works only in combination with "before" argument.')
+
+            slice_to = decode_cursor(before) - 1
             slice_from = slice_to - last
             if slice_from < 0:
                 slice_from = 0
diff --git a/openlobby/sanitizers.py b/openlobby/core/api/sanitizers.py
similarity index 100%
rename from openlobby/sanitizers.py
rename to openlobby/core/api/sanitizers.py
diff --git a/openlobby/core/api/schema.py b/openlobby/core/api/schema.py
new file mode 100644
index 0000000000000000000000000000000000000000..d206bdf7b124773d6cfd9e54ed0fc97cfd09f151
--- /dev/null
+++ b/openlobby/core/api/schema.py
@@ -0,0 +1,97 @@
+import graphene
+from graphene import relay
+
+from . import types
+from ..models import OpenIdClient
+from .paginator import Paginator
+from .sanitizers import extract_text
+from .. import search
+from ..models import User
+
+
+class AuthorsConnection(relay.Connection):
+    total_count = graphene.Int()
+
+    class Meta:
+        node = types.Author
+
+
+class SearchReportsConnection(relay.Connection):
+    total_count = graphene.Int()
+
+    class Meta:
+        node = types.Report
+
+
+def _get_authors_cache(ids):
+    users = User.objects.filter(id__in=ids)
+    return {u.id: types.User.from_db(u) for u in users}
+
+
+class Query:
+    highlight_help = ('Whether search matches should be marked with tag <mark>.'
+        ' Default: false')
+
+    node = relay.Node.Field()
+    authors = relay.ConnectionField(
+        AuthorsConnection,
+        description='List of Authors. Returns first 10 nodes if pagination is not specified.',
+    )
+    search_reports = relay.ConnectionField(
+        SearchReportsConnection,
+        description='Fulltext search in Reports. Returns first 10 nodes if pagination is not specified.',
+        query=graphene.String(description='Text to search for.'),
+        highlight=graphene.Boolean(default_value=False, description=highlight_help),
+    )
+    viewer = graphene.Field(types.User, description='Active user viewing API.')
+    login_shortcuts = graphene.List(
+        types.LoginShortcut,
+        description='Shortcuts for login. Use with LoginByShortcut mutation.',
+    )
+
+    def resolve_authors(self, info, **kwargs):
+        paginator = Paginator(**kwargs)
+
+        total = User.objects.filter(is_author=True).count()
+        authors = User.objects.filter(is_author=True)[paginator.slice_from:paginator.slice_to]
+
+        page_info = paginator.get_page_info(total)
+
+        edges = []
+        for i, author in enumerate(authors):
+            cursor = paginator.get_edge_cursor(i + 1)
+            node = types.Author.from_db(author)
+            edges.append(AuthorsConnection.Edge(node=node, cursor=cursor))
+
+        return AuthorsConnection(page_info=page_info, edges=edges, total_count=total)
+
+    def resolve_search_reports(self, info, **kwargs):
+        paginator = Paginator(**kwargs)
+        query = kwargs.get('query', '')
+        query = extract_text(query)
+        params = {
+            'highlight': kwargs.get('highlight'),
+        }
+        response = search.query_reports(query, paginator, **params)
+        total = response.hits.total
+        page_info = paginator.get_page_info(total)
+
+        edges = []
+        if len(response) > 0:
+            authors = _get_authors_cache(ids=[r.author_id for r in response])
+            for i, report in enumerate(response):
+                cursor = paginator.get_edge_cursor(i + 1)
+                node = types.Report.from_es(report, author=authors[report.author_id])
+                edges.append(SearchReportsConnection.Edge(node=node, cursor=cursor))
+
+        return SearchReportsConnection(page_info=page_info, edges=edges, total_count=total)
+
+    def resolve_viewer(self, info, **kwargs):
+        if info.context.user.is_authenticated:
+            return types.User.from_db(info.context.user)
+        else:
+            return None
+
+    def resolve_login_shortcuts(self, info, **kwargs):
+        clients = OpenIdClient.objects.filter(is_shortcut=True)
+        return [types.LoginShortcut.from_db(c) for c in clients]
diff --git a/openlobby/types.py b/openlobby/core/api/types.py
similarity index 59%
rename from openlobby/types.py
rename to openlobby/core/api/types.py
index bd4f9359b54a599adf616ddb6a27f20362ccd2f3..78640dfe3934d6e250ffc0b9bb7f7e903b4e7aaa 100644
--- a/openlobby/types.py
+++ b/openlobby/core/api/types.py
@@ -3,9 +3,10 @@ import graphene
 from graphene import relay
 from graphene.types.json import JSONString
 
-from .documents import UserDoc, ReportDoc, OpenIdClientDoc
+from ..documents import ReportDoc
+from .. import models
 from .paginator import Paginator
-from . import search
+from .. import search
 
 
 def get_higlighted(hit, field):
@@ -16,7 +17,7 @@ def get_higlighted(hit, field):
 
 
 class Report(graphene.ObjectType):
-    author = graphene.Field(lambda: User)
+    author = graphene.Field(lambda: Author)
     date = graphene.String()
     published = graphene.String()
     title = graphene.String()
@@ -43,13 +44,29 @@ class Report(graphene.ObjectType):
             provided_benefit=get_higlighted(report, 'provided_benefit'),
             our_participants=get_higlighted(report, 'our_participants'),
             other_participants=get_higlighted(report, 'other_participants'),
-            extra=report.extra._d_,
+            extra=report.extra,
+        )
+
+    @classmethod
+    def from_db(cls, report):
+        return cls(
+            id=report.id,
+            author=report.author,
+            date=report.date,
+            published=report.published,
+            title=report.title,
+            body=report.body,
+            received_benefit=report.received_benefit,
+            provided_benefit=report.provided_benefit,
+            our_participants=report.our_participants,
+            other_participants=report.other_participants,
+            extra=report.extra,
         )
 
     @classmethod
     def get_node(cls, info, id):
         try:
-            report = ReportDoc.get(id, using=info.context['es'], index=info.context['index'])
+            report = ReportDoc.get(id)
         except NotFoundError:
             return None
 
@@ -66,36 +83,65 @@ class ReportConnection(relay.Connection):
 
 
 class User(graphene.ObjectType):
-    name = graphene.String()
     openid_uid = graphene.String()
+    first_name = graphene.String()
+    last_name = graphene.String()
     email = graphene.String()
+    is_author = graphene.Boolean()
     extra = JSONString()
-    reports = relay.ConnectionField(ReportConnection)
 
     class Meta:
         interfaces = (relay.Node, )
 
     @classmethod
-    def from_es(cls, user):
+    def from_db(cls, user):
         return cls(
-            id=user.meta.id,
-            name=user.name,
+            id=user.id,
             openid_uid=user.openid_uid,
+            first_name=user.first_name,
+            last_name=user.last_name,
             email=user.email,
-            extra=user.extra._d_,
+            is_author=user.is_author,
+            extra=user.extra,
+        )
+
+    @classmethod
+    def get_node(cls, info, id):
+        if not info.context.user.is_authenticated:
+            return None
+        if int(id) != info.context.user.id:
+            return None
+        return cls.from_db(info.context.user)
+
+
+class Author(graphene.ObjectType):
+    first_name = graphene.String()
+    last_name = graphene.String()
+    extra = JSONString()
+    reports = relay.ConnectionField(ReportConnection)
+
+    class Meta:
+        interfaces = (relay.Node, )
+
+    @classmethod
+    def from_db(cls, user):
+        return cls(
+            id=user.id,
+            first_name=user.first_name,
+            last_name=user.last_name,
+            extra=user.extra,
         )
 
     @classmethod
     def get_node(cls, info, id):
         try:
-            user = UserDoc.get(id, using=info.context['es'], index=info.context['index'])
-        except NotFoundError:
+            return cls.from_db(models.User.objects.get(id=id, is_author=True))
+        except models.User.DoesNotExist:
             return None
-        return cls.from_es(user)
 
     def resolve_reports(self, info, **kwargs):
         paginator = Paginator(**kwargs)
-        response = search.reports_by_author(self.id, paginator, **info.context)
+        response = search.reports_by_author(self.id, paginator)
         total = response.hits.total
         page_info = paginator.get_page_info(total)
 
@@ -115,16 +161,13 @@ class LoginShortcut(graphene.ObjectType):
         interfaces = (relay.Node, )
 
     @classmethod
-    def from_es(cls, openid_client):
-        return cls(
-            id=openid_client.meta.id,
-            name=openid_client.name_x,
-        )
+    def from_db(cls, openid_client):
+        return cls(id=openid_client.id, name=openid_client.name)
 
     @classmethod
     def get_node(cls, info, id):
         try:
-            openid_client = OpenIdClientDoc.get(id, using=info.context['es'], index=info.context['index'])
-        except NotFoundError:
+            client = models.OpenIdClient.objects.get(id=id)
+            return cls.from_db(client)
+        except models.OpenIdClient.DoesNotExist:
             return None
-        return cls.from_es(openid_client)
diff --git a/openlobby/core/api/utils.py b/openlobby/core/api/utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..e2503c6f2b511527b797c270d6c758ecfbe2d7c7
--- /dev/null
+++ b/openlobby/core/api/utils.py
@@ -0,0 +1,6 @@
+from django.http import JsonResponse
+
+
+def graphql_error_response(message, status_code=400):
+    error = {'message': message}
+    return JsonResponse({'errors': [error]}, status=status_code)
diff --git a/openlobby/core/apps.py b/openlobby/core/apps.py
new file mode 100644
index 0000000000000000000000000000000000000000..26f78a8e673340121f68a92930e2830bc58d269d
--- /dev/null
+++ b/openlobby/core/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class CoreConfig(AppConfig):
+    name = 'core'
diff --git a/openlobby/core/auth.py b/openlobby/core/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..b49b3a94ef675e132f8df1e008812b8a6ba7b714
--- /dev/null
+++ b/openlobby/core/auth.py
@@ -0,0 +1,19 @@
+from django.conf import settings
+import jwt
+import time
+
+
+def create_access_token(username, expiration=None):
+    if expiration is None:
+        expiration = int(time.time() + settings.SESSION_EXPIRATION)
+    payload = {
+        'sub': username,
+        'exp': expiration,
+    }
+    token = jwt.encode(payload, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
+    return token.decode('utf-8')
+
+
+def parse_access_token(token):
+    payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
+    return payload['sub']
diff --git a/openlobby/core/documents.py b/openlobby/core/documents.py
new file mode 100644
index 0000000000000000000000000000000000000000..c4574a742838d579322db333777de2baf0d686d9
--- /dev/null
+++ b/openlobby/core/documents.py
@@ -0,0 +1,36 @@
+from django.conf import settings
+from django_elasticsearch_dsl import DocType, Index, fields
+import json
+
+from .models import Report
+
+
+report = Index('report')
+
+
+@report.doc_type
+class ReportDoc(DocType):
+    author_id = fields.IntegerField()
+    title = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER)
+    body = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER)
+    received_benefit = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER)
+    provided_benefit = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER)
+    our_participants = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER)
+    other_participants = fields.TextField(analyzer=settings.ES_TEXT_ANALYZER)
+    # there is no support for JSONField now, so we serialize it to keyword
+    extra_serialized = fields.KeywordField()
+
+    def prepare_extra_serialized(self, instance):
+        return json.dumps(instance.extra)
+
+    @property
+    def extra(self):
+        return json.loads(self.extra_serialized)
+
+    class Meta:
+        model = Report
+
+        fields = [
+            'date',
+            'published',
+        ]
diff --git a/openlobby/core/middleware.py b/openlobby/core/middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6c1083c595d7ae6f7b7e0cba48a9561b455c8aa
--- /dev/null
+++ b/openlobby/core/middleware.py
@@ -0,0 +1,33 @@
+import re
+
+from .api.utils import graphql_error_response
+from .auth import parse_access_token
+from .models import User
+
+
+class TokenAuthMiddleware:
+    """Custom authentication middleware which using JWT token."""
+
+    def __init__(self, get_response):
+        self.get_response = get_response
+
+    def __call__(self, request):
+        auth_header = request.META.get('HTTP_AUTHORIZATION')
+        if auth_header is not None:
+            m = re.match(r'Bearer (?P<token>.+)', auth_header)
+            if m:
+                token = m.group('token')
+            else:
+                return graphql_error_response('Wrong Authorization header. Expected: "Bearer <token>"')
+
+            try:
+                username = parse_access_token(token)
+            except Exception:
+                return graphql_error_response('Invalid Token.', 401)
+
+            try:
+                request.user = User.objects.get(username=username)
+            except User.DoesNotExist:
+                pass
+
+        return self.get_response(request)
diff --git a/openlobby/core/migrations/0001_initial.py b/openlobby/core/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..6e16fd07305e7a325fdbe10617b20365eed5f2b3
--- /dev/null
+++ b/openlobby/core/migrations/0001_initial.py
@@ -0,0 +1,93 @@
+# Generated by Django 2.0.1 on 2018-01-16 00:25
+
+from django.conf import settings
+import django.contrib.auth.models
+import django.contrib.auth.validators
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('auth', '0009_alter_user_last_name_max_length'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='User',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('password', models.CharField(max_length=128, verbose_name='password')),
+                ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
+                ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
+                ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
+                ('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
+                ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
+                ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
+                ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
+                ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
+                ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
+                ('openid_uid', models.CharField(db_index=True, max_length=255, unique=True)),
+                ('extras', django.contrib.postgres.fields.jsonb.JSONField()),
+                ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
+                ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
+            ],
+            options={
+                'verbose_name': 'user',
+                'verbose_name_plural': 'users',
+                'abstract': False,
+            },
+            managers=[
+                ('objects', django.contrib.auth.models.UserManager()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='LoginAttempt',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('state', models.CharField(db_index=True, max_length=50, unique=True)),
+                ('redirect_uri', models.CharField(max_length=255)),
+                ('expiration', models.IntegerField()),
+            ],
+        ),
+        migrations.CreateModel(
+            name='OpenIdClient',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=255)),
+                ('is_shortcut', models.BooleanField(default=False)),
+                ('client_id', models.CharField(max_length=255)),
+                ('client_secret', models.CharField(max_length=255)),
+                ('issuer', models.CharField(db_index=True, max_length=255, unique=True)),
+                ('authorization_endpoint', models.CharField(max_length=255)),
+                ('token_endpoint', models.CharField(max_length=255)),
+                ('userinfo_endpoint', models.CharField(max_length=255)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='Report',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('date', models.DateTimeField()),
+                ('published', models.DateTimeField()),
+                ('title', models.TextField()),
+                ('body', models.TextField()),
+                ('received_benefit', models.TextField()),
+                ('provided_benefit', models.TextField()),
+                ('our_participants', models.TextField()),
+                ('other_participants', models.TextField()),
+                ('extra', django.contrib.postgres.fields.jsonb.JSONField()),
+                ('author', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='loginattempt',
+            name='openid_client',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.OpenIdClient'),
+        ),
+    ]
diff --git a/openlobby/core/migrations/0002_auto_20180116_2349.py b/openlobby/core/migrations/0002_auto_20180116_2349.py
new file mode 100644
index 0000000000000000000000000000000000000000..db82fcff43eea3210500cb4d4da010f13834d3fd
--- /dev/null
+++ b/openlobby/core/migrations/0002_auto_20180116_2349.py
@@ -0,0 +1,49 @@
+# Generated by Django 2.0.1 on 2018-01-16 22:49
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='report',
+            name='extra',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='report',
+            name='other_participants',
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='report',
+            name='our_participants',
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='report',
+            name='provided_benefit',
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='report',
+            name='received_benefit',
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='report',
+            name='title',
+            field=models.TextField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='extras',
+            field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
+        ),
+    ]
diff --git a/openlobby/core/migrations/0003_auto_20180117_0030.py b/openlobby/core/migrations/0003_auto_20180117_0030.py
new file mode 100644
index 0000000000000000000000000000000000000000..b48c6ca7f0f47d8d7aabdf806ea40175902868fc
--- /dev/null
+++ b/openlobby/core/migrations/0003_auto_20180117_0030.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.1 on 2018-01-16 23:30
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0002_auto_20180116_2349'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='user',
+            old_name='extras',
+            new_name='extra',
+        ),
+    ]
diff --git a/openlobby/core/migrations/0004_user_is_author.py b/openlobby/core/migrations/0004_user_is_author.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a8e43e40003605a4240ef6fb5313ea118c8936d
--- /dev/null
+++ b/openlobby/core/migrations/0004_user_is_author.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.1 on 2018-01-27 10:30
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0003_auto_20180117_0030'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='is_author',
+            field=models.BooleanField(default=False),
+        ),
+    ]
diff --git a/openlobby/core/migrations/0005_auto_20180208_1210.py b/openlobby/core/migrations/0005_auto_20180208_1210.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce3b8ec06ee7c9a645d3b12b0eec675870fbef0c
--- /dev/null
+++ b/openlobby/core/migrations/0005_auto_20180208_1210.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.0.2 on 2018-02-08 11:10
+
+from django.db import migrations, models
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0004_user_is_author'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='loginattempt',
+            old_name='redirect_uri',
+            new_name='app_redirect_uri',
+        ),
+        migrations.AlterField(
+            model_name='report',
+            name='published',
+            field=models.DateTimeField(default=django.utils.timezone.now),
+        ),
+    ]
diff --git a/openlobby/core/migrations/0006_auto_20180208_1544.py b/openlobby/core/migrations/0006_auto_20180208_1544.py
new file mode 100644
index 0000000000000000000000000000000000000000..90eb680791f7ec9859bce5fa141cc5c4d00b9387
--- /dev/null
+++ b/openlobby/core/migrations/0006_auto_20180208_1544.py
@@ -0,0 +1,19 @@
+# Generated by Django 2.0.2 on 2018-02-08 14:44
+
+from django.db import migrations, models
+import openlobby.core.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0005_auto_20180208_1210'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='loginattempt',
+            name='expiration',
+            field=models.IntegerField(default=openlobby.core.models.get_login_attempt_expiration),
+        ),
+    ]
diff --git a/openlobby/core/migrations/0007_auto_20180209_1809.py b/openlobby/core/migrations/0007_auto_20180209_1809.py
new file mode 100644
index 0000000000000000000000000000000000000000..898503bc22fcf9ae91e8e1198bcd8cdca320b1e3
--- /dev/null
+++ b/openlobby/core/migrations/0007_auto_20180209_1809.py
@@ -0,0 +1,25 @@
+# Generated by Django 2.0.2 on 2018-02-09 17:09
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0006_auto_20180208_1544'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='openidclient',
+            name='authorization_endpoint',
+        ),
+        migrations.RemoveField(
+            model_name='openidclient',
+            name='token_endpoint',
+        ),
+        migrations.RemoveField(
+            model_name='openidclient',
+            name='userinfo_endpoint',
+        ),
+    ]
diff --git a/openlobby/core/migrations/0008_loginattempt_openid_uid.py b/openlobby/core/migrations/0008_loginattempt_openid_uid.py
new file mode 100644
index 0000000000000000000000000000000000000000..7243cf4b4e815bbdfe72527bc2c56de8a9abb51e
--- /dev/null
+++ b/openlobby/core/migrations/0008_loginattempt_openid_uid.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.2 on 2018-02-17 16:21
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0007_auto_20180209_1809'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='loginattempt',
+            name='openid_uid',
+            field=models.CharField(max_length=255, null=True),
+        ),
+    ]
diff --git a/openlobby/core/migrations/0009_auto_20180217_1850.py b/openlobby/core/migrations/0009_auto_20180217_1850.py
new file mode 100644
index 0000000000000000000000000000000000000000..88afb4af1cd95ba957cdc1d231bbe994378c5fce
--- /dev/null
+++ b/openlobby/core/migrations/0009_auto_20180217_1850.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0.2 on 2018-02-17 17:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0008_loginattempt_openid_uid'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='user',
+            name='openid_uid',
+            field=models.CharField(max_length=255, null=True),
+        ),
+    ]
diff --git a/openlobby/core/migrations/__init__.py b/openlobby/core/migrations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/openlobby/core/models.py b/openlobby/core/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..09314c9593a3efa365cd240adb4cd2ccce7cae7b
--- /dev/null
+++ b/openlobby/core/models.py
@@ -0,0 +1,59 @@
+from django.conf import settings
+from django.db import models
+from django.contrib.auth.models import AbstractUser
+from django.contrib.postgres.fields import JSONField
+from django.utils import timezone
+import time
+
+
+class User(AbstractUser):
+    """Custom user model. For simplicity we store OpenID 'sub' identifier in
+    username field.
+    """
+    openid_uid = models.CharField(max_length=255, null=True)
+    extra = JSONField(null=True, blank=True)
+    is_author = models.BooleanField(default=False)
+
+
+class OpenIdClient(models.Model):
+    """Stores connection parameters for OpenID Clients. Some may be used as
+    login shortcuts.
+    """
+    name = models.CharField(max_length=255)
+    is_shortcut = models.BooleanField(default=False)
+    client_id = models.CharField(max_length=255)
+    client_secret = models.CharField(max_length=255)
+    issuer = models.CharField(max_length=255, unique=True, db_index=True)
+
+    def __str__(self):
+        return self.name
+
+
+def get_login_attempt_expiration():
+    return int(time.time() + settings.LOGIN_ATTEMPT_EXPIRATION)
+
+
+class LoginAttempt(models.Model):
+    """Temporary login attempt details which persists redirects."""
+    openid_client = models.ForeignKey(OpenIdClient, on_delete=models.CASCADE)
+    state = models.CharField(max_length=50, unique=True, db_index=True)
+    app_redirect_uri = models.CharField(max_length=255)
+    openid_uid = models.CharField(max_length=255, null=True)
+    expiration = models.IntegerField(default=get_login_attempt_expiration)
+
+
+class Report(models.Model):
+    author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
+    date = models.DateTimeField()
+    published = models.DateTimeField(default=timezone.now)
+    title = models.TextField(null=True, blank=True)
+    body = models.TextField()
+    received_benefit = models.TextField(null=True, blank=True)
+    provided_benefit = models.TextField(null=True, blank=True)
+    our_participants = models.TextField(null=True, blank=True)
+    other_participants = models.TextField(null=True, blank=True)
+    extra = JSONField(null=True, blank=True)
+
+    def save(self, *args, **kwargs):
+        super().save(*args, **kwargs)
+        User.objects.filter(id=self.author.id).update(is_author=True)
diff --git a/openlobby/core/openid.py b/openlobby/core/openid.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ce9422a7cf012cd702d9178fd95cd7114d8a2aa
--- /dev/null
+++ b/openlobby/core/openid.py
@@ -0,0 +1,67 @@
+from django.conf import settings
+from oic.oic import Client
+from oic.oic.message import (
+    AuthorizationResponse,
+    RegistrationResponse,
+    ClaimsRequest,
+    Claims,
+)
+from oic.utils.authn.client import CLIENT_AUTHN_METHOD
+
+
+def discover_issuer(openid_uid):
+    client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
+    return client.discover(openid_uid)
+
+
+def init_client_for_shortcut(openid_client_obj):
+    client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
+    reg_info = {
+        'client_id': openid_client_obj.client_id,
+        'client_secret': openid_client_obj.client_secret,
+        'redirect_uris': [settings.REDIRECT_URI],
+    }
+    client_reg = RegistrationResponse(**reg_info)
+    client.store_registration_info(client_reg)
+    client.provider_config(openid_client_obj.issuer)
+    return client
+
+
+def register_client(issuer):
+    client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
+    client.provider_config(issuer)
+    params = {
+        'redirect_uris': [settings.REDIRECT_URI],
+        'client_name': settings.SITE_NAME,
+    }
+    client.register(client.provider_info['registration_endpoint'], **params)
+    return client
+
+
+def get_authorization_url(client, state):
+    args = {
+        'client_id': client.client_id,
+        'response_type': 'code',
+        'scope': 'openid',
+        'state': state,
+        'redirect_uri': client.registration_response['redirect_uris'][0],
+        'claims': ClaimsRequest(userinfo=Claims(
+            given_name={'essential': True},
+            family_name={'essential': True},
+            email={'essential': True},
+        )),
+    }
+
+    auth_req = client.construct_AuthorizationRequest(request_args=args)
+    return auth_req.request(client.provider_info['authorization_endpoint'])
+
+
+def get_user_info(client, query_string):
+    """Processes query string from OpenID redirect and returns user info."""
+    aresp = client.parse_response(AuthorizationResponse, info=query_string,
+        sformat='urlencoded')
+
+    args = {'code': aresp['code'], 'redirect_uri': settings.REDIRECT_URI}
+    client.do_access_token_request(state=aresp['state'], request_args=args)
+
+    return client.do_user_info_request(state=aresp['state'])
diff --git a/openlobby/search.py b/openlobby/core/search.py
similarity index 59%
rename from openlobby/search.py
rename to openlobby/core/search.py
index d682b4cd35492dcf0aa0fe1053e7aee53249db09..27ef112755667cba816abd78dbf98d1842e75cc4 100644
--- a/openlobby/search.py
+++ b/openlobby/core/search.py
@@ -1,4 +1,4 @@
-from .documents import ReportDoc, OpenIdClientDoc
+from .documents import ReportDoc
 
 
 HIGHLIGHT_PARAMS = {
@@ -8,10 +8,10 @@ HIGHLIGHT_PARAMS = {
 }
 
 
-def query_reports(query, paginator, *, es, index, highlight=False):
+def query_reports(query, paginator, *, highlight=False):
     fields = ['title', 'body', 'received_benefit', 'provided_benefit',
         'our_participants', 'other_participants']
-    s = ReportDoc.search(using=es, index=index)
+    s = ReportDoc.search()
     if query != '':
         s = s.query('multi_match', query=query, fields=fields)
     if highlight:
@@ -21,15 +21,9 @@ def query_reports(query, paginator, *, es, index, highlight=False):
     return s.execute()
 
 
-def reports_by_author(author_id, paginator, *, es, index):
-    s = ReportDoc.search(using=es, index=index)
+def reports_by_author(author_id, paginator):
+    s = ReportDoc.search()
     s = s.filter('term', author_id=author_id)
     s = s.sort('-published')
     s = s[paginator.slice_from:paginator.slice_to]
     return s.execute()
-
-
-def login_shortcuts(*, es, index):
-    s = OpenIdClientDoc.search(using=es, index=index)
-    s = s.filter('term', is_shortcut=True)
-    return s.execute()
diff --git a/openlobby/core/templates/core/index.html b/openlobby/core/templates/core/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..25a2631d8efe95cb3150315a930342f8bed9981a
--- /dev/null
+++ b/openlobby/core/templates/core/index.html
@@ -0,0 +1,6 @@
+<html>
+  <body>
+    <h1>Open Lobby Server</h1>
+    <p>GraphQL API endpoint and GraphiQL is at: <a href="/graphql">/graphql</a></p>
+  </body>
+</html>
diff --git a/openlobby/core/views.py b/openlobby/core/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a270d2d443e5d88b6bde72df36ad28a919eeca9
--- /dev/null
+++ b/openlobby/core/views.py
@@ -0,0 +1,51 @@
+from django.shortcuts import redirect
+from django.views import View
+from django.views.generic.base import TemplateView
+import time
+import urllib.parse
+
+from .auth import create_access_token
+from .models import LoginAttempt, User
+from .openid import (
+    init_client_for_shortcut,
+    get_user_info,
+)
+
+
+class IndexView(TemplateView):
+    template_name = 'core/index.html'
+
+
+class LoginRedirectView(View):
+
+    # TODO redirect to app_redirect_uri on fail
+    def get(self, request, **kwargs):
+        query_string = request.META['QUERY_STRING']
+
+        # get login attempt
+        state = urllib.parse.parse_qs(query_string)['state'][0]
+        la = LoginAttempt.objects.select_related('openid_client').get(state=state)
+        app_redirect_uri = la.app_redirect_uri
+
+        if la.expiration < time.time():
+            # TODO redirect to app_redirect_uri with fail
+            raise NotImplementedError
+
+        # delete login attempt so it can be used just once
+        # TODO delete breaks it with LoginAttempt.DoesNotExist exception, why?!
+        # la.delete()
+
+        client = init_client_for_shortcut(la.openid_client)
+        user_info = get_user_info(client, query_string)
+
+        user, created = User.objects.get_or_create(username=user_info['sub'], defaults={
+            'username': user_info['sub'],
+            'first_name': user_info['given_name'],
+            'last_name': user_info['family_name'],
+            'email': user_info['email'],
+            'openid_uid': la.openid_uid,
+        })
+
+        token = create_access_token(user.username)
+        token_qs = urllib.parse.urlencode({'token': token})
+        return redirect('{}?{}'.format(app_redirect_uri, token_qs))
diff --git a/openlobby/documents.py b/openlobby/documents.py
deleted file mode 100644
index b07c66eef0718515cf8ca4b73f156ca46f4576f2..0000000000000000000000000000000000000000
--- a/openlobby/documents.py
+++ /dev/null
@@ -1,93 +0,0 @@
-from elasticsearch_dsl import DocType, Text, Date, Object, Keyword, Integer, Boolean
-import time
-
-from .settings import ES_TEXT_ANALYZER
-
-
-"""
-Don't forget to add a new document to the list of all documents at the end of
-the file.
-"""
-
-
-class UserDoc(DocType):
-    openid_uid = Keyword()
-    name = Text(analyzer=ES_TEXT_ANALYZER)
-    email = Keyword()
-    extra = Object()
-
-    class Meta:
-        doc_type = 'user'
-
-    @classmethod
-    def get_by_openid_uid(cls, openid_uid, *, es, index):
-        response = cls.search(using=es, index=index).query('match', openid_uid=openid_uid).execute()
-        return response.hits[0] if response.hits.total > 0 else None
-
-
-class ReportDoc(DocType):
-    author_id = Keyword()
-    date = Date()
-    published = Date()
-    title = Text(analyzer=ES_TEXT_ANALYZER)
-    body = Text(analyzer=ES_TEXT_ANALYZER)
-    received_benefit = Text(analyzer=ES_TEXT_ANALYZER)
-    provided_benefit = Text(analyzer=ES_TEXT_ANALYZER)
-    our_participants = Text(analyzer=ES_TEXT_ANALYZER)
-    other_participants = Text(analyzer=ES_TEXT_ANALYZER)
-    extra = Object()
-
-    class Meta:
-        doc_type = 'report'
-
-
-class OpenIdClientDoc(DocType):
-    # TODO conflict in field type
-    name_x = Keyword()
-    is_shortcut = Boolean()
-    client_id = Keyword()
-    client_secret = Keyword()
-    issuer = Keyword()
-    authorization_endpoint = Keyword()
-    token_endpoint = Keyword()
-    userinfo_endpoint = Keyword()
-
-    class Meta:
-        doc_type = 'open-id-client'
-
-
-class LoginAttemptDoc(DocType):
-    openid_uid = Keyword()
-    redirect_uri = Keyword()
-    state = Keyword()
-    nonce = Keyword()
-    client_id = Keyword()
-    client_secret = Keyword()
-    expiration = Integer()  # UTC timestamp
-
-    class Meta:
-        doc_type = 'login-attempt'
-
-
-class SessionDoc(DocType):
-    user_id = Keyword()
-    expiration = Integer()  # UTC timestamp
-
-    class Meta:
-        doc_type = 'session'
-
-    @classmethod
-    def get_active(cls, session_id, *, es, index):
-        session = cls.get(session_id, using=es, index=index, ignore=404)
-        if session and session.expiration > time.time():
-            return session
-        return None
-
-
-all_documents = (
-    UserDoc,
-    ReportDoc,
-    LoginAttemptDoc,
-    SessionDoc,
-    OpenIdClientDoc,
-)
diff --git a/openlobby/management.py b/openlobby/management.py
deleted file mode 100644
index 828668e9e370f1e0a42b4c518c12baddc2b30bce..0000000000000000000000000000000000000000
--- a/openlobby/management.py
+++ /dev/null
@@ -1,120 +0,0 @@
-import re
-
-from .documents import all_documents
-
-
-class IndexAlreadyExistsError(Exception):
-    pass
-
-
-class AliasAlreadyExistsError(Exception):
-    pass
-
-
-INDEX_SETTINGS = {
-    'settings': {
-        'analysis': {
-            'filter': {
-                'czech_stop': {
-                    'type': 'stop',
-                    'stopwords': '_czech_',
-                },
-                'czech_stemmer': {
-                    'type': 'stemmer',
-                    'language': 'czech',
-                },
-                'cs_CZ': {
-                    'type': 'hunspell',
-                    'locale': 'cs_CZ',
-                    'dedup': True,
-                },
-                'czech_synonym': {
-                    'type': 'synonym',
-                    'synonyms_path': 'analysis/cs_CZ/synonym.txt',
-                },
-            },
-            'analyzer': {
-                'czech': {
-                    'tokenizer': 'standard',
-                    'filter': [
-                        'icu_folding',
-                        'lowercase',
-                        'czech_synonym',
-                        'czech_stop',
-                        'czech_stemmer',
-                        'cs_CZ',
-                    ]
-                }
-            }
-        }
-    }
-}
-
-
-def bootstrap_es(es, index):
-    if es.indices.exists(index):
-        return
-
-    print('Bootstrapping index and documents.')
-    init_alias(es, index)
-
-
-def init_documents(es, index):
-    """Initializes all documents."""
-    for doc in all_documents:
-        doc.init(index=index, using=es)
-
-
-def get_new_index_version(index):
-    """Returns name of new version of index."""
-    m = re.match(r'(.+)_v([\d]+)$', index)
-    if not m:
-        raise ValueError('Cannot get version from index name "{}"'.format(index))
-
-    alias = m.group(1)
-    version = m.group(2)
-    new_version = int(version) + 1
-    return '{}_v{}'.format(alias, new_version)
-
-
-def create_index(es, name):
-    """Creates index with INDEX_SETTINGS and all documents (mappings)."""
-    if es.indices.exists(name):
-        raise IndexAlreadyExistsError('Index "{}" already exists.'.format(name))
-
-    es.indices.create(name, body=INDEX_SETTINGS)
-    init_documents(es, name)
-
-
-def init_alias(es, alias):
-    """Initializes alias for the first time. Creates index v1 for alias."""
-    if es.indices.exists(alias):
-        raise AliasAlreadyExistsError('Alias "{}" already exists.'.format(alias))
-
-    index = '{}_v1'.format(alias)
-    create_index(es, index)
-    es.indices.put_alias(name=alias, index=index)
-
-
-def reindex(es, alias):
-    """
-    Reindexes index by alias into new version (with actual index settings and
-    mappings definitions) and switches alias.
-    """
-    response = es.indices.get_alias(alias)
-    source = list(response.keys())[0]
-
-    dest = get_new_index_version(source)
-    create_index(es, dest)
-
-    es.reindex(body={
-        'source': {'index': source},
-        'dest': {'index': dest},
-    })
-
-    es.indices.update_aliases(body={
-        'actions': [
-            {'remove': {'index': source, 'alias': alias}},
-            {'add': {'index': dest, 'alias': alias}},
-        ],
-    })
diff --git a/openlobby/mutations.py b/openlobby/mutations.py
deleted file mode 100644
index a6b6086901506650a21b455437f303a01ceea5f5..0000000000000000000000000000000000000000
--- a/openlobby/mutations.py
+++ /dev/null
@@ -1,251 +0,0 @@
-import arrow
-from flask import g
-import graphene
-from graphene import relay
-from graphene.types.datetime import DateTime
-from graphql_relay import from_global_id
-from oic.oic import rndstr
-from oic.oic.message import AuthorizationResponse
-import time
-import urllib.parse
-
-from .auth import (
-    get_login_attempt_expiration_time,
-    get_session_expiration_time,
-    create_access_token,
-)
-from .documents import (
-    UserDoc,
-    LoginAttemptDoc,
-    SessionDoc,
-    ReportDoc,
-    OpenIdClientDoc,
-)
-from .openid import (
-    init_client_for_uid,
-    init_client_for_shortcut,
-    register_client,
-    get_authorization_url,
-    set_registration_info,
-    do_access_token_request,
-)
-from .types import Report
-from .utils import get_viewer
-from .sanitizers import strip_all_tags
-
-
-class Login(relay.ClientIDMutation):
-
-    class Input:
-        openid_uid = graphene.String(required=True)
-        redirect_uri = graphene.String(required=True)
-
-    authorization_url = graphene.String()
-
-    @classmethod
-    def mutate_and_get_payload(cls, root, info, **input):
-        openid_uid = input['openid_uid']
-        redirect_uri = input['redirect_uri']
-
-        # prepare OpenID client
-        client = init_client_for_uid(openid_uid)
-        client = register_client(client, redirect_uri)
-
-        # prepare login attempt details
-        state = rndstr(32)
-        nonce = rndstr()
-        expiration = get_login_attempt_expiration_time()
-
-        # save login attempt into ES
-        data = {
-            'meta': {'id': client.client_id},
-            'state': state,
-            'nonce': nonce,
-            'client_id': client.client_id,
-            'client_secret': client.client_secret,
-            'openid_uid': openid_uid,
-            'redirect_uri': redirect_uri,
-            'expiration': expiration,
-        }
-        login_attempt = LoginAttemptDoc(**data)
-        login_attempt.save(using=info.context['es'], index=info.context['index'])
-
-        # already registered user?
-        user = UserDoc.get_by_openid_uid(openid_uid, **info.context)
-        is_new_user = user is None
-
-        # get OpenID authorization url
-        authorization_url = get_authorization_url(client, state, nonce, is_new_user)
-
-        return Login(authorization_url=authorization_url)
-
-
-class LoginByShortcut(relay.ClientIDMutation):
-
-    class Input:
-        shortcut_id = relay.GlobalID(required=True)
-        redirect_uri = graphene.String(required=True)
-
-    authorization_url = graphene.String()
-
-    @classmethod
-    def mutate_and_get_payload(cls, root, info, **input):
-        shortcut_id = input['shortcut_id']
-        redirect_uri = input['redirect_uri']
-
-        type, id = from_global_id(shortcut_id)
-        openid_client_data = OpenIdClientDoc.get(id, using=info.context['es'],
-            index=info.context['index'])
-
-        # prepare OpenID client
-        client = init_client_for_shortcut(openid_client_data, redirect_uri)
-
-        # TODO
-        """
-        # prepare login attempt details
-        state = rndstr(32)
-        nonce = rndstr()
-        expiration = get_login_attempt_expiration_time()
-
-        # save login attempt into ES
-        data = {
-            'meta': {'id': client.client_id},
-            'state': state,
-            'nonce': nonce,
-            'client_id': client.client_id,
-            'client_secret': client.client_secret,
-            'openid_uid': openid_uid,
-            'redirect_uri': redirect_uri,
-            'expiration': expiration,
-        }
-        login_attempt = LoginAttemptDoc(**data)
-        login_attempt.save(using=info.context['es'], index=info.context['index'])
-
-        # already registered user?
-        user = UserDoc.get_by_openid_uid(openid_uid, **info.context)
-        is_new_user = user is None
-
-        # get OpenID authorization url
-        authorization_url = get_authorization_url(client, state, nonce, is_new_user)
-        """
-
-        authorization_url = 'http://localhost/foo'
-        return LoginByShortcut(authorization_url=authorization_url)
-
-
-class LoginRedirect(relay.ClientIDMutation):
-
-    class Input:
-        query_string = graphene.String(required=True)
-
-    access_token = graphene.String()
-
-    @classmethod
-    def mutate_and_get_payload(cls, root, info, **input):
-        query_string = input['query_string']
-
-        # get login attempt from ES
-        qs_data = urllib.parse.parse_qs(query_string)
-        la = LoginAttemptDoc.get(qs_data['client_id'], using=info.context['es'],
-            index=info.context['index'])
-
-        # delete login attempt so it can be used just once
-        la.delete(using=info.context['es'])
-
-        # check login attempt expiration
-        if la['expiration'] < time.time():
-            raise Exception('Login attempt expired.')
-
-        # reconstruct OpenID Client
-        client = init_client_for_uid(la['openid_uid'])
-        client = set_registration_info(client, la['client_id'], la['client_secret'], la['redirect_uri'])
-
-        # process query string from OpenID redirect
-        aresp = client.parse_response(AuthorizationResponse, info=query_string, sformat='urlencoded')
-        code = aresp['code']
-        state = aresp['state']
-
-        # check state
-        if state != la['state']:
-            raise ValueError('Wrong query_string.')
-
-        # OpenID access token request
-        do_access_token_request(client, code, state)
-
-        # OpenID user info request
-        user_info = client.do_user_info_request(state=state)
-
-        # get or create User
-        user = UserDoc.get_by_openid_uid(la['openid_uid'], **info.context)
-        if user is None:
-            user = UserDoc(openid_uid=la['openid_uid'], name=user_info['name'], email=user_info['email'])
-            user.save(using=info.context['es'], index=info.context['index'])
-
-        # create session
-        expiration = get_session_expiration_time()
-        session = SessionDoc(user_id=user.meta.id, expiration=expiration)
-        session.save(using=info.context['es'], index=info.context['index'])
-
-        # create access token for session
-        token = create_access_token(session.meta.id, expiration)
-
-        return LoginRedirect(access_token=token)
-
-
-class Logout(relay.ClientIDMutation):
-    success = graphene.Boolean()
-
-    @classmethod
-    def mutate_and_get_payload(cls, root, info, **input):
-        viewer = get_viewer(info)
-        if viewer is None:
-            raise Exception('User must be logged in to perform this mutation.')
-
-        session_id = g.get('session_id')
-        session = SessionDoc.get(session_id, using=info.context['es'], index=info.context['index'])
-        session.delete(using=info.context['es'], index=info.context['index'])
-
-        return Logout(success=True)
-
-
-class NewReport(relay.ClientIDMutation):
-
-    class Input:
-        title = graphene.String(required=True)
-        body = graphene.String(required=True)
-        received_benefit = graphene.String()
-        provided_benefit = graphene.String()
-        our_participants = graphene.String()
-        other_participants = graphene.String()
-        date = DateTime(required=True)
-
-    report = graphene.Field(Report)
-
-    @classmethod
-    def mutate_and_get_payload(cls, root, info, **input):
-        viewer = get_viewer(info)
-        if viewer is None:
-            raise Exception('User must be logged in to perform this mutation.')
-
-        data = {
-            'author_id': viewer.id,
-            'published': arrow.utcnow().isoformat(),
-            'title': strip_all_tags(input.get('title', '')),
-            'body': strip_all_tags(input.get('body', '')),
-            'received_benefit': strip_all_tags(input.get('received_benefit', '')),
-            'provided_benefit': strip_all_tags(input.get('provided_benefit', '')),
-            'our_participants': strip_all_tags(input.get('our_participants', '')),
-            'other_participants': strip_all_tags(input.get('other_participants', '')),
-            'date': input.get('date'),
-        }
-        report = ReportDoc(**data)
-        report.save(using=info.context['es'], index=info.context['index'])
-        return NewReport(report=Report.from_es(report, author=viewer))
-
-
-class Mutation(graphene.ObjectType):
-    login = Login.Field()
-    login_by_shortcut = LoginByShortcut.Field()
-    login_redirect = LoginRedirect.Field()
-    logout = Logout.Field()
-    new_report = NewReport.Field()
diff --git a/openlobby/openid.py b/openlobby/openid.py
deleted file mode 100644
index f336e0af71b6468e5136eb559cb7bace2744ae61..0000000000000000000000000000000000000000
--- a/openlobby/openid.py
+++ /dev/null
@@ -1,83 +0,0 @@
-from oic.oic import Client
-from oic.oic.message import (
-    ProviderConfigurationResponse,
-    RegistrationResponse,
-    ClaimsRequest,
-    Claims,
-)
-from oic.utils.authn.client import CLIENT_AUTHN_METHOD
-
-from .settings import SITE_NAME
-
-
-def init_client_for_uid(openid_uid):
-    client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
-    issuer = client.discover(openid_uid)
-    client.provider_config(issuer)
-    return client
-
-
-def init_client_for_shortcut(data, redirect_uri):
-    client = Client(client_authn_method=CLIENT_AUTHN_METHOD)
-    set_registration_info(client, data['client_id'], data['client_secret'], redirect_uri)
-    info = {
-        'issuer': data['issuer'],
-        'authorization_endpoint': data['authorization_endpoint'],
-        'token_endpoint': data['token_endpoint'],
-        'userinfo_endpoint': data['userinfo_endpoint'],
-    }
-    client.provider_info = ProviderConfigurationResponse(**info)
-    return client
-
-
-def register_client(client, redirect_uri):
-    params = {
-        'redirect_uris': [redirect_uri],
-        'client_name': SITE_NAME,
-    }
-    client.register(client.provider_info['registration_endpoint'], **params)
-    return client
-
-
-def get_authorization_url(client, state, nonce, is_new_user=True):
-    args = {
-        'client_id': client.client_id,
-        'response_type': 'code',
-        'scope': ['openid'],
-        'nonce': nonce,
-        'state': state,
-        'redirect_uri': client.registration_response['redirect_uris'][0],
-    }
-
-    if is_new_user:
-        args['claims'] = ClaimsRequest(
-            userinfo=Claims(
-                email={'essential': True},
-                name={'essential': True},
-            )
-        )
-
-    auth_req = client.construct_AuthorizationRequest(request_args=args)
-    url = auth_req.request(client.provider_info['authorization_endpoint'])
-    return url
-
-
-def set_registration_info(client, client_id, client_secret, redirect_uri):
-    info = {
-        'client_id': client_id,
-        'client_secret': client_secret,
-        'redirect_uris': [redirect_uri],
-    }
-    client_reg = RegistrationResponse(**info)
-    client.store_registration_info(client_reg)
-    return client
-
-
-def do_access_token_request(client, code, state):
-    args = {
-        'code': code,
-        'client_id': client.client_id,
-        'client_secret': client.client_secret,
-        'redirect_uri': client.registration_response['redirect_uris'][0],
-    }
-    client.do_access_token_request(state=state, request_args=args)
diff --git a/openlobby/schema.py b/openlobby/schema.py
index c76ed3b13f0049600574afd9c7404e2a1b1f3701..5772e2fd8a448384edf4b427c50b69aaa3c3ca41 100644
--- a/openlobby/schema.py
+++ b/openlobby/schema.py
@@ -1,73 +1,17 @@
 import graphene
-from graphene import relay
 
-from .types import Report, User, LoginShortcut
-from .documents import UserDoc
-from .paginator import Paginator
-from .mutations import Mutation
-from .sanitizers import extract_text
-from .utils import get_viewer
-from . import search
+from openlobby.core.api.mutations import Mutation as CoreMutation
+from openlobby.core.api.schema import Query as CoreQuery
+from openlobby.core.api.types import Author, User, Report, LoginShortcut
 
 
-class SearchReportsConnection(relay.Connection):
-    total_count = graphene.Int()
+class Query(CoreQuery, graphene.ObjectType):
+    pass
 
-    class Meta:
-        node = Report
 
+class Mutation(CoreMutation, graphene.ObjectType):
+    pass
 
-def get_authors(ids, *, es, index):
-    response = UserDoc.mget(ids, using=es, index=index)
-    return {a.meta.id: User.from_es(a) for a in response}
 
-
-class Query(graphene.ObjectType):
-    highlight_help = ('Whether search matches should be marked with tag <mark>.'
-        ' Default: false')
-
-    node = relay.Node.Field()
-    search_reports = relay.ConnectionField(
-        SearchReportsConnection,
-        description='Fulltext search in Reports.',
-        query=graphene.String(description='Text to search for.'),
-        highlight=graphene.Boolean(default_value=False, description=highlight_help),
-    )
-    viewer = graphene.Field(User, description='Active user viewing API.')
-    login_shortcuts = graphene.List(
-        LoginShortcut,
-        description='Shortcuts for login. Use with LoginByShortcut mutation.',
-    )
-
-    def resolve_search_reports(self, info, **kwargs):
-        paginator = Paginator(**kwargs)
-        query = kwargs.get('query', '')
-        query = extract_text(query)
-        params = {
-            'es': info.context['es'],
-            'index': info.context['index'],
-            'highlight': kwargs.get('highlight'),
-        }
-        response = search.query_reports(query, paginator, **params)
-        total = response.hits.total
-        page_info = paginator.get_page_info(total)
-
-        edges = []
-        if len(response) > 0:
-            authors = get_authors(ids=[r.author_id for r in response], **info.context)
-            for i, report in enumerate(response):
-                cursor = paginator.get_edge_cursor(i + 1)
-                node = Report.from_es(report, author=authors[report.author_id])
-                edges.append(SearchReportsConnection.Edge(node=node, cursor=cursor))
-
-        return SearchReportsConnection(page_info=page_info, edges=edges, total_count=total)
-
-    def resolve_viewer(self, info, **kwargs):
-        return get_viewer(info)
-
-    def resolve_login_shortcuts(self, info, **kwargs):
-        response = search.login_shortcuts(**info.context)
-        return [LoginShortcut.from_es(ls) for ls in response]
-
-
-schema = graphene.Schema(query=Query, mutation=Mutation, types=[User, Report])
+schema = graphene.Schema(query=Query, mutation=Mutation,
+    types=[Author, User, Report, LoginShortcut])
diff --git a/openlobby/server.py b/openlobby/server.py
deleted file mode 100644
index e35d9475071b2c223b820dbd1991a46a43f02c0a..0000000000000000000000000000000000000000
--- a/openlobby/server.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import os
-from flask import Flask
-from elasticsearch import Elasticsearch
-
-from .auth import AuthGraphQLView
-from .management import bootstrap_es
-from .schema import schema
-from .settings import ES_INDEX
-
-
-app = Flask(__name__)
-
-
-es_dsn = os.environ.get('ELASTICSEARCH_DSN', 'http://localhost:9200')
-es_client = Elasticsearch(es_dsn)
-
-bootstrap_es(es_client, ES_INDEX)
-
-
-@app.route('/')
-def hello():
-    return 'Open Lobby Server\n\nAPI is at: /graphql', 200, {'Content-Type': 'text/plain; charset=utf-8'}
-
-
-app.add_url_rule('/graphql', view_func=AuthGraphQLView.as_view(
-    'graphql', schema=schema, graphiql=True, context={'es': es_client, 'index': ES_INDEX}))
diff --git a/openlobby/settings.py b/openlobby/settings.py
index c1a8b8b7e0653c64d1b4c383bebefb0ef4b65770..dad0d61f52a23995c5bf42f891a44b1cfa485b6a 100644
--- a/openlobby/settings.py
+++ b/openlobby/settings.py
@@ -1,4 +1,145 @@
 import os
+import dsnparse
+
+# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# SECURITY WARNING: don't run with debug turned on in production!
+DEBUG = 'DEBUG' in os.environ
+
+# SECURITY WARNING: keep the secret key used in production secret!
+SECRET_KEY = os.environ.get('SECRET_KEY')
+if not SECRET_KEY:
+    if DEBUG:
+        SECRET_KEY = 'not-secret-at-all'
+    else:
+        raise RuntimeError('Missing SECRET_KEY environment variable.')
+
+ALLOWED_HOSTS = ['*']
+
+
+# Application definition
+
+INSTALLED_APPS = [
+    'django.contrib.admin',
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.staticfiles',
+    'graphene_django',
+    'django_elasticsearch_dsl',
+    'openlobby.core',
+]
+
+MIDDLEWARE = [
+    'django.middleware.security.SecurityMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.common.CommonMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'openlobby.core.middleware.TokenAuthMiddleware',
+    'django.middleware.clickjacking.XFrameOptionsMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+]
+
+ROOT_URLCONF = 'openlobby.urls'
+
+TEMPLATES = [
+    {
+        'BACKEND': 'django.template.backends.django.DjangoTemplates',
+        'DIRS': [],
+        'APP_DIRS': True,
+        'OPTIONS': {
+            'context_processors': [
+                'django.template.context_processors.debug',
+                'django.template.context_processors.request',
+                'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
+            ],
+        },
+    },
+]
+
+WSGI_APPLICATION = 'openlobby.wsgi.application'
+
+AUTH_USER_MODEL = 'core.User'
+
+DATABASE_DSN = os.environ.get('DATABASE_DSN', 'postgresql://db:db@localhost:5432/openlobby')
+db = dsnparse.parse(DATABASE_DSN)
+assert db.scheme == 'postgresql'
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.postgresql',
+        'NAME': db.paths[0],
+        'USER': db.username,
+        'PASSWORD': db.password,
+        'HOST': db.host,
+        'PORT': db.port,
+    }
+}
+
+ELASTICSEARCH_DSL = {
+    'default': {
+        'hosts': os.environ.get('ELASTICSEARCH_DSN', 'http://localhost:9200'),
+    },
+}
+
+ELASTICSEARCH_DSL_INDEX_SETTINGS = {
+    'number_of_shards': 1,
+    'number_of_replicas': 0,
+}
+
+# Password validation
+
+AUTH_PASSWORD_VALIDATORS = [
+    {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
+    {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
+    {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
+    {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
+]
+
+
+# Internationalization
+
+LANGUAGE_CODE = 'cs'
+
+TIME_ZONE = 'Europe/Prague'
+
+USE_I18N = True
+
+USE_L10N = True
+
+USE_TZ = True
+
+
+# Static files (CSS, JavaScript, Images)
+
+STATIC_URL = '/static/'
+
+
+LOGGING = {
+    'version': 1,
+    'handlers': {
+        'console': {
+            'class': 'logging.StreamHandler',
+        },
+    },
+    'loggers': {
+        'django': {
+            'handlers': ['console'],
+            'level': 'INFO' if DEBUG else 'WARNING',
+        },
+    },
+}
+
+
+GRAPHENE = {
+    'SCHEMA': 'openlobby.schema.schema'
+}
+
+###############################################################################
+# Custom settings
 
 # Elasticsearch index
 ES_INDEX = os.environ.get('ES_INDEX', 'openlobby')
@@ -6,11 +147,6 @@ ES_INDEX = os.environ.get('ES_INDEX', 'openlobby')
 # default analyzer for text fields
 ES_TEXT_ANALYZER = os.environ.get('ES_TEXT_ANALYZER', 'czech')
 
-# secret key for signing tokens
-SECRET_KEY = os.environ.get('SECRET_KEY')
-if not SECRET_KEY:
-    raise RuntimeError('Missing SECRET_KEY environment variable.')
-
 # signature algorithm JSON Web Tokens
 JWT_ALGORITHM = 'HS512'
 
@@ -22,3 +158,6 @@ SESSION_EXPIRATION = 60 * 60 * 24 * 14
 
 # name of the site used in OpenID authentication
 SITE_NAME = os.environ.get('SITE_NAME', 'Open Lobby')
+
+# redirect URI used in OpenID authentication
+REDIRECT_URI = os.environ.get('REDIRECT_URI', 'http://localhost:8010/login-redirect')
diff --git a/openlobby/urls.py b/openlobby/urls.py
new file mode 100644
index 0000000000000000000000000000000000000000..f9f5fed53ee512e7cc1ad4eeb090775ac5d0f9e3
--- /dev/null
+++ b/openlobby/urls.py
@@ -0,0 +1,13 @@
+from django.contrib import admin
+from django.urls import path
+from django.views.decorators.csrf import csrf_exempt
+from graphene_django.views import GraphQLView
+
+from openlobby.core.views import IndexView, LoginRedirectView
+
+urlpatterns = [
+    path('', IndexView.as_view(), name='index'),
+    path('login-redirect', LoginRedirectView.as_view(), name='login-redirect'),
+    path('admin/', admin.site.urls),
+    path('graphql', csrf_exempt(GraphQLView.as_view(graphiql=True))),
+]
diff --git a/openlobby/utils.py b/openlobby/utils.py
deleted file mode 100644
index e26871b964847a96b0fc2ad20d335613fc47c256..0000000000000000000000000000000000000000
--- a/openlobby/utils.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from flask import g
-
-from .documents import SessionDoc
-from .types import User
-
-
-def get_viewer(info):
-    """Resolves actual viewer and caches it into 'g'."""
-    if not hasattr(g, 'viewer'):
-        session_id = g.get('session_id', None)
-        if session_id is None:
-            g.viewer = None
-        else:
-            session = SessionDoc.get_active(session_id, **info.context)
-            if session is None:
-                g.viewer = None
-            else:
-                g.viewer = User.get_node(info, session.user_id)
-    return g.viewer
diff --git a/openlobby/wsgi.py b/openlobby/wsgi.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f399cc41e6446c3d2f303983b18fd88301d45c9
--- /dev/null
+++ b/openlobby/wsgi.py
@@ -0,0 +1,16 @@
+"""
+WSGI config for openlobby project.
+
+It exposes the WSGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/
+"""
+
+import os
+
+from django.core.wsgi import get_wsgi_application
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "openlobby.settings")
+
+application = get_wsgi_application()
diff --git a/pytest.ini b/pytest.ini
index e1b7a4018edbb066d5fde12841ba8022fcdae421..854a7af384b1a0a85b3288df7b656f7d733fe535 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -1,3 +1,5 @@
 [pytest]
+DJANGO_SETTINGS_MODULE = openlobby.settings
+python_files = tests.py test_*.py *_tests.py
 env =
     SECRET_KEY=justForTests
diff --git a/requirements.txt b/requirements.txt
index 13ed9ee43cec7473f77dbdc30fc658f2534af996..e0eb091d6f94f00c0c1d0716ba76059aef15d4c9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,12 +1,16 @@
+Django>=2,<2.1
 graphene>=2.0,<3.0
-flask>=0.12,<0.13
-flask_graphQL>=1.4,<1.5
+graphene-django>=2.0,<3.0
 elasticsearch-dsl>=5.3.0,<6.0.0
-pytest>=3.2.3,<3.3.0
+django-elasticsearch-dsl>=0.4.4,<0.5
+pytest>=3.3,<4.0
+pytest-django>=3.1.2,<3.2
 pytest-env>=0.6.2,<0.7
 oic>=0.12.0,<0.13
 pyjwt>=1.5.3,<1.6
 iso8601>=0.1.12,<0.2
-arrow>=0.10.0,<0.11
+arrow>=0.12.0,<0.13
 bleach>=2.1.1,<2.2
 snapshottest>=0.5.0,<0.6
+psycopg2>=2.7,<2.8
+dsnparse>=0.1.11,<0.2
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/conftest.py b/tests/conftest.py
index 111f1cf97bf5866adfb09db3d667f17bb67093b7..6d9cc6b844b8a82264ff793dfe73c4f31c7593b7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,32 +1,37 @@
-from elasticsearch import Elasticsearch
-import os
 import pytest
-import random
-import string
+from django_elasticsearch_dsl.test import ESTestCase
 
 
-from openlobby.management import init_alias
+class DummyTestCase:
+    def setUp(self):
+        pass
 
+    def tearDown(self):
+        pass
 
-@pytest.fixture(scope='session')
-def es():
-    """Elasticsearch client."""
-    es_dsn = os.environ.get('ELASTICSEARCH_DSN', 'http://localhost:9200')
-    es_client = Elasticsearch(es_dsn)
-    yield es_client
-    es_client.indices.delete('test_*')
+
+class TestCase(ESTestCase, DummyTestCase):
+    pass
 
 
 @pytest.fixture
-def index_name():
-    """Random test index name."""
-    length = 10
-    word = ''.join(random.choice(string.ascii_lowercase) for i in range(length))
-    return 'test_{}'.format(word)
+def django_es():
+    """Setup and teardown of Elasticsearch test indices."""
+    testCase = TestCase()
+    testCase.setUp()
+    yield
+    testCase.tearDown()
 
 
-@pytest.fixture
-def index(es, index_name):
-    """Initialized index."""
-    init_alias(es, index_name)
-    return index_name
+def pytest_addoption(parser):
+    parser.addoption('--issuer', action='store',
+        help='OpenID Provider (server) issuer URL. Provider must allow client registration.')
+
+
+@pytest.fixture(scope='session')
+def issuer(request):
+    """OpenID Provider issuer URL."""
+    url = request.config.getoption('--issuer')
+    if url is None:
+        pytest.skip('Missing OpenID Provider URL.')
+    return url
diff --git a/tests/dummy.py b/tests/dummy.py
new file mode 100644
index 0000000000000000000000000000000000000000..dbdb7dcd6b16e1a5cda5ca745792d34787a6013a
--- /dev/null
+++ b/tests/dummy.py
@@ -0,0 +1,84 @@
+import arrow
+
+from openlobby.core.models import Report, User
+
+
+authors = [
+    {
+        'id': 1,
+        'username': 'Wolf',
+        'first_name': 'Winston',
+        'last_name': 'Wolfe',
+        'is_author': True,
+        'extra': {'movies': 1},
+    },
+    {
+        'id': 2,
+        'username': 'sponge',
+        'first_name': 'Spongebob',
+        'last_name': 'Squarepants',
+        'is_author': True,
+    },
+    {
+        'id': 3,
+        'username': 'shaun',
+        'first_name': 'Shaun',
+        'last_name': 'Sheep',
+        'is_author': True,
+    },
+]
+
+reports = [
+    {
+        'id': 1,
+        'date': arrow.get(2018, 1, 1).datetime,
+        'published': arrow.get(2018, 1, 2).datetime,
+        'title': 'The Fellowship of the Ring',
+        'body': 'Long story short: we got the Ring!',
+        'received_benefit': 'The Ring',
+        'provided_benefit': '',
+        'our_participants': 'Frodo, Gandalf',
+        'other_participants': 'Saruman',
+    },
+    {
+        'id': 2,
+        'date': arrow.get(2018, 1, 5).datetime,
+        'published': arrow.get(2018, 1, 10).datetime,
+        'title': 'The Two Towers',
+        'body': 'Another long story.',
+        'received_benefit': 'Mithrill Jacket',
+        'provided_benefit': '',
+        'our_participants': 'Frodo, Gimli, Legolas',
+        'other_participants': 'Saruman, Sauron',
+        'extra': {'rings': 1},
+    },
+    {
+        'id': 3,
+        'date': arrow.get(2018, 1, 7).datetime,
+        'published': arrow.get(2018, 1, 8).datetime,
+        'title': 'The Return of the King',
+        'body': 'Aragorn is the King. And we have lost the Ring.',
+        'received_benefit': '',
+        'provided_benefit': 'The Ring',
+        'our_participants': 'Aragorn',
+        'other_participants': 'Sauron',
+    },
+]
+
+
+def prepare_reports():
+    author1 = User.objects.create(**authors[0])
+    author2 = User.objects.create(**authors[1])
+    Report.objects.create(author=author1, **reports[0])
+    Report.objects.create(author=author2, **reports[1])
+    Report.objects.create(author=author1, **reports[2])
+
+
+def prepare_report():
+    author = User.objects.create(**authors[0])
+    Report.objects.create(author=author, **reports[0])
+
+
+def prepare_authors():
+    for author in authors:
+        User.objects.create(**author)
diff --git a/tests/mutations/__init__.py b/tests/mutations/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/mutations/snapshots/__init__.py b/tests/mutations/snapshots/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/mutations/snapshots/snap_test_login.py b/tests/mutations/snapshots/snap_test_login.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a36af706ac9e7d00be5361a9589e8636245927c
--- /dev/null
+++ b/tests/mutations/snapshots/snap_test_login.py
@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_login__known_openid_client 1'] = {
+    'userinfo': {
+        'email': {
+            'essential': True
+        },
+        'family_name': {
+            'essential': True
+        },
+        'given_name': {
+            'essential': True
+        }
+    }
+}
+
+snapshots['test_login__new_openid_client 1'] = {
+    'userinfo': {
+        'email': {
+            'essential': True
+        },
+        'family_name': {
+            'essential': True
+        },
+        'given_name': {
+            'essential': True
+        }
+    }
+}
diff --git a/tests/mutations/snapshots/snap_test_login_by_shortcut.py b/tests/mutations/snapshots/snap_test_login_by_shortcut.py
new file mode 100644
index 0000000000000000000000000000000000000000..6fe834f111a4cd7b14863c5eb635217f3b69679a
--- /dev/null
+++ b/tests/mutations/snapshots/snap_test_login_by_shortcut.py
@@ -0,0 +1,22 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_login_by_shortcut 1'] = {
+    'userinfo': {
+        'email': {
+            'essential': True
+        },
+        'family_name': {
+            'essential': True
+        },
+        'given_name': {
+            'essential': True
+        }
+    }
+}
diff --git a/tests/mutations/snapshots/snap_test_new_report.py b/tests/mutations/snapshots/snap_test_new_report.py
new file mode 100644
index 0000000000000000000000000000000000000000..1f223bbe8031ead1b9af28180b1e2cf44b584070
--- /dev/null
+++ b/tests/mutations/snapshots/snap_test_new_report.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_unauthorized 1'] = {
+    'data': {
+        'newReport': None
+    },
+    'errors': [
+        {
+            'locations': [
+                {
+                    'column': 9,
+                    'line': 3
+                }
+            ],
+            'message': 'User must be logged in to perform this mutation.'
+        }
+    ]
+}
+
+snapshots['test_full_report 1'] = {
+    'data': {
+        'newReport': {
+            'report': {
+                'author': {
+                    'extra': None,
+                    'firstName': 'Winston',
+                    'id': 'QXV0aG9yOjE=',
+                    'lastName': 'Wolfe'
+                },
+                'body': 'I visited Tesla factory and talked with Elon Musk.',
+                'date': '2018-01-01 00:00:00+00:00',
+                'extra': None,
+                'id': '__STRIPPED__',
+                'otherParticipants': 'Elon Musk',
+                'ourParticipants': 'me',
+                'providedBenefit': 'nothing',
+                'published': '__STRIPPED__',
+                'receivedBenefit': 'Tesla Model S',
+                'title': 'Free Tesla'
+            }
+        }
+    }
+}
diff --git a/tests/mutations/test_login.py b/tests/mutations/test_login.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa489ba73a9fb51f39dbf6069cfda63eef0db174
--- /dev/null
+++ b/tests/mutations/test_login.py
@@ -0,0 +1,85 @@
+import json
+import pytest
+import re
+from urllib.parse import urlparse, urlunparse, parse_qs
+from unittest.mock import patch
+
+from openlobby.core.models import OpenIdClient, LoginAttempt
+from openlobby.core.openid import register_client
+
+
+pytestmark = pytest.mark.django_db
+
+
+def check_authorization_url(authorization_url, oid_client, state, snapshot):
+    url = urlparse(authorization_url)
+    url_without_query = urlunparse((url.scheme, url.netloc, url.path, '', '', ''))
+    assert url_without_query == '{}/protocol/openid-connect/auth'.format(oid_client.issuer)
+
+    qs = parse_qs(url.query)
+    assert qs['client_id'][0] == oid_client.client_id
+    assert qs['response_type'][0] == 'code'
+    assert qs['scope'][0] == 'openid'
+    assert qs['redirect_uri'][0] == 'http://localhost:8010/login-redirect'
+    assert qs['state'][0] == state
+    snapshot.assert_match(json.loads(qs['claims'][0]))
+
+
+def test_login__known_openid_client(issuer, client, snapshot):
+    oc = register_client(issuer)
+    oid_client = OpenIdClient.objects.create(name='Test', issuer=issuer,
+        client_id=oc.client_id, client_secret=oc.client_secret)
+
+    app_redirect_uri = 'http://i.am.pirate'
+    openid_uid = 'wolf@openid.provider'
+    # Keycloak server used for tests does not support issuer discovery by UID, so we mock it
+    with patch('openlobby.core.api.mutations.discover_issuer', return_value=issuer) as mock:
+        res = client.post('/graphql', {'query': """
+        mutation {{
+            login (input: {{ openidUid: "{uid}", redirectUri: "{uri}" }}) {{
+                authorizationUrl
+            }}
+        }}
+        """.format(uid=openid_uid, uri=app_redirect_uri)})
+        mock.assert_called_once_with(openid_uid)
+
+    response = res.json()
+    assert 'errors' not in response
+    authorization_url = response['data']['login']['authorizationUrl']
+
+    la = LoginAttempt.objects.get(openid_client__id=oid_client.id)
+    assert la.app_redirect_uri == app_redirect_uri
+    assert la.openid_uid == openid_uid
+
+    check_authorization_url(authorization_url, oid_client, la.state, snapshot)
+
+
+def test_login__new_openid_client(issuer, client, snapshot):
+    app_redirect_uri = 'http://i.am.pirate'
+    openid_uid = 'wolf@openid.provider'
+    # Keycloak server used for tests does not support issuer discovery by UID, so we mock it
+    with patch('openlobby.core.api.mutations.discover_issuer', return_value=issuer) as mock:
+        res = client.post('/graphql', {'query': """
+        mutation {{
+            login (input: {{ openidUid: "{uid}", redirectUri: "{uri}" }}) {{
+                authorizationUrl
+            }}
+        }}
+        """.format(uid=openid_uid, uri=app_redirect_uri)})
+        mock.assert_called_once_with(openid_uid)
+
+    response = res.json()
+    assert 'errors' not in response
+    authorization_url = response['data']['login']['authorizationUrl']
+
+    oid_client = OpenIdClient.objects.get()
+    assert oid_client.name == issuer
+    assert oid_client.issuer == issuer
+    assert re.match(r'\w+', oid_client.client_id)
+    assert re.match(r'\w+', oid_client.client_secret)
+
+    la = LoginAttempt.objects.get(openid_client__id=oid_client.id)
+    assert la.app_redirect_uri == app_redirect_uri
+    assert la.openid_uid == openid_uid
+
+    check_authorization_url(authorization_url, oid_client, la.state, snapshot)
diff --git a/tests/mutations/test_login_by_shortcut.py b/tests/mutations/test_login_by_shortcut.py
new file mode 100644
index 0000000000000000000000000000000000000000..e26a320dce10051b87db1e2c9f0704845099cff7
--- /dev/null
+++ b/tests/mutations/test_login_by_shortcut.py
@@ -0,0 +1,33 @@
+from graphql_relay import to_global_id
+import pytest
+
+from openlobby.core.models import OpenIdClient, LoginAttempt
+from openlobby.core.openid import register_client
+
+from .test_login import check_authorization_url
+
+pytestmark = pytest.mark.django_db
+
+
+def test_login_by_shortcut(issuer, client, snapshot):
+    oc = register_client(issuer)
+    oid_client = OpenIdClient.objects.create(name='Test', is_shortcut=True,
+        issuer=issuer, client_id=oc.client_id, client_secret=oc.client_secret)
+
+    app_redirect_uri = 'http://i.am.pirate'
+    res = client.post('/graphql', {'query': """
+    mutation {{
+        loginByShortcut (input: {{ shortcutId: "{id}", redirectUri: "{uri}" }}) {{
+            authorizationUrl
+        }}
+    }}
+    """.format(id=to_global_id('LoginShortcut', oid_client.id), uri=app_redirect_uri)})
+    response = res.json()
+    assert 'errors' not in response
+    authorization_url = response['data']['loginByShortcut']['authorizationUrl']
+
+    la = LoginAttempt.objects.get(openid_client__id=oid_client.id)
+    assert la.app_redirect_uri == app_redirect_uri
+    assert la.openid_uid is None
+
+    check_authorization_url(authorization_url, oid_client, la.state, snapshot)
diff --git a/tests/mutations/test_new_report.py b/tests/mutations/test_new_report.py
new file mode 100644
index 0000000000000000000000000000000000000000..ac64910ce937f59dd67f35ad2ffe376ce65aa0d9
--- /dev/null
+++ b/tests/mutations/test_new_report.py
@@ -0,0 +1,151 @@
+import pytest
+import arrow
+import json
+import re
+
+from openlobby.core.auth import create_access_token
+from openlobby.core.models import User, Report
+
+
+pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')]
+
+
+@pytest.fixture(autouse=True)
+def setup():
+    User.objects.create(id=1, is_author=True, username='wolfe',
+        first_name='Winston', last_name='Wolfe', email='winston@wolfe.com')
+
+
+def call_api(client, query, input, username=None):
+    variables = json.dumps({'input': input})
+    if username is None:
+        res = client.post('/graphql', {'query': query, 'variables': variables})
+    else:
+        token = create_access_token(username)
+        auth_header = 'Bearer {}'.format(token)
+        res = client.post('/graphql', {'query': query, 'variables': variables},
+            HTTP_AUTHORIZATION=auth_header)
+    return res.json()
+
+
+def test_unauthorized(client, snapshot):
+    query = """
+    mutation newReport ($input: NewReportInput!) {
+        newReport (input: $input) {
+            report {
+                id
+            }
+        }
+    }
+    """
+    input = {
+        'title': 'Short Story',
+        'body': 'I told you!',
+        'date': arrow.utcnow().isoformat(),
+    }
+
+    response = call_api(client, query, input)
+
+    snapshot.assert_match(response)
+
+
+def test_full_report(client, snapshot):
+    query = """
+    mutation newReport ($input: NewReportInput!) {
+        newReport (input: $input) {
+            report {
+                id
+                date
+                published
+                title
+                body
+                receivedBenefit
+                providedBenefit
+                ourParticipants
+                otherParticipants
+                extra
+                author {
+                    id
+                    firstName
+                    lastName
+                    extra
+                }
+            }
+        }
+    }
+    """
+    date = arrow.get(2018, 1, 1)
+    title = 'Free Tesla'
+    body = 'I visited Tesla factory and talked with Elon Musk.'
+    received_benefit = 'Tesla Model S'
+    provided_benefit = 'nothing'
+    our_participants = 'me'
+    other_participants = 'Elon Musk'
+    input = {
+        'title': title,
+        'body': body,
+        'receivedBenefit': received_benefit,
+        'providedBenefit': provided_benefit,
+        'ourParticipants': our_participants,
+        'otherParticipants': other_participants,
+        'date': date.isoformat(),
+    }
+
+    response = call_api(client, query, input, 'wolfe')
+
+    # published date is set on save, changing between test runs, so we strip it
+    # from snapshot
+    published = response['data']['newReport']['report']['published']
+    response['data']['newReport']['report']['published'] = '__STRIPPED__'
+
+    # There is a strange issue with tests, that report get's ID 2 when all tests
+    # are run. Even when the there is just one Report in database. I tried to
+    # debug it, no luck to solve. So I just strip ID from snapshot and check it.
+    id = response['data']['newReport']['report']['id']
+    response['data']['newReport']['report']['id'] = '__STRIPPED__'
+    assert re.match(r'\w+', id)
+
+    snapshot.assert_match(response)
+
+    report = Report.objects.get()
+    assert report.author_id == 1
+    assert report.date == date.datetime
+    assert report.published == arrow.get(published).datetime
+    assert report.title == title
+    assert report.body == body
+    assert report.received_benefit == received_benefit
+    assert report.provided_benefit == provided_benefit
+    assert report.our_participants == our_participants
+    assert report.other_participants == other_participants
+    assert report.extra is None
+
+
+def test_input_sanitization(client):
+    query = """
+    mutation newReport ($input: NewReportInput!) {
+        newReport (input: $input) {
+            report {
+                id
+            }
+        }
+    }
+    """
+    input = {
+        'title': '<s>No</s> tags',
+        'body': 'some <a href="http://foo">link</a> <br>in body',
+        'receivedBenefit': '<b>coffee</b>',
+        'providedBenefit': '<li>tea',
+        'ourParticipants': 'me, <u>myself</u>',
+        'otherParticipants': '<strong>you!</strong>',
+        'date': arrow.utcnow().isoformat(),
+    }
+
+    call_api(client, query, input, 'wolfe')
+
+    report = Report.objects.get()
+    assert report.title == 'No tags'
+    assert report.body == 'some link in body'
+    assert report.received_benefit == 'coffee'
+    assert report.provided_benefit == 'tea'
+    assert report.our_participants == 'me, myself'
+    assert report.other_participants == 'you!'
diff --git a/tests/schema/__init__.py b/tests/schema/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/schema/snapshots/__init__.py b/tests/schema/snapshots/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/tests/schema/snapshots/snap_test_authors.py b/tests/schema/snapshots/snap_test_authors.py
new file mode 100644
index 0000000000000000000000000000000000000000..88acda2ab25a1e728a38899b4661f6fc0c4237e7
--- /dev/null
+++ b/tests/schema/snapshots/snap_test_authors.py
@@ -0,0 +1,184 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_all 1'] = {
+    'data': {
+        'authors': {
+            'edges': [
+                {
+                    'cursor': 'MQ==',
+                    'node': {
+                        'extra': '{"movies": 1}',
+                        'firstName': 'Winston',
+                        'id': 'QXV0aG9yOjE=',
+                        'lastName': 'Wolfe'
+                    }
+                },
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'extra': None,
+                        'firstName': 'Spongebob',
+                        'id': 'QXV0aG9yOjI=',
+                        'lastName': 'Squarepants'
+                    }
+                },
+                {
+                    'cursor': 'Mw==',
+                    'node': {
+                        'extra': None,
+                        'firstName': 'Shaun',
+                        'id': 'QXV0aG9yOjM=',
+                        'lastName': 'Sheep'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'Mw==',
+                'hasNextPage': False,
+                'hasPreviousPage': False,
+                'startCursor': 'MQ=='
+            },
+            'totalCount': 3
+        }
+    }
+}
+
+snapshots['test_first 1'] = {
+    'data': {
+        'authors': {
+            'edges': [
+                {
+                    'cursor': 'MQ==',
+                    'node': {
+                        'firstName': 'Winston',
+                        'id': 'QXV0aG9yOjE=',
+                        'lastName': 'Wolfe'
+                    }
+                },
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'firstName': 'Spongebob',
+                        'id': 'QXV0aG9yOjI=',
+                        'lastName': 'Squarepants'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'Mg==',
+                'hasNextPage': True,
+                'hasPreviousPage': False,
+                'startCursor': 'MQ=='
+            },
+            'totalCount': 3
+        }
+    }
+}
+
+snapshots['test_first_after 1'] = {
+    'data': {
+        'authors': {
+            'edges': [
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'firstName': 'Spongebob',
+                        'id': 'QXV0aG9yOjI=',
+                        'lastName': 'Squarepants'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'Mg==',
+                'hasNextPage': True,
+                'hasPreviousPage': True,
+                'startCursor': 'Mg=='
+            },
+            'totalCount': 3
+        }
+    }
+}
+
+snapshots['test_last 1'] = {
+    'data': {
+        'authors': None
+    },
+    'errors': [
+        {
+            'locations': [
+                {
+                    'column': 9,
+                    'line': 3
+                }
+            ],
+            'message': 'Pagination "last" works only in combination with "before" argument.'
+        }
+    ]
+}
+
+snapshots['test_last_before 1'] = {
+    'data': {
+        'authors': {
+            'edges': [
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'firstName': 'Spongebob',
+                        'id': 'QXV0aG9yOjI=',
+                        'lastName': 'Squarepants'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'Mg==',
+                'hasNextPage': True,
+                'hasPreviousPage': True,
+                'startCursor': 'Mg=='
+            },
+            'totalCount': 3
+        }
+    }
+}
+
+snapshots['test_with_reports 1'] = {
+    'data': {
+        'authors': {
+            'edges': [
+                {
+                    'node': {
+                        'firstName': 'Winston',
+                        'id': 'QXV0aG9yOjE=',
+                        'lastName': 'Wolfe',
+                        'reports': {
+                            'edges': [
+                                {
+                                    'cursor': 'MQ==',
+                                    'node': {
+                                        'body': 'Long story short: we got the Ring!',
+                                        'date': '2018-01-01 00:00:00+00:00',
+                                        'extra': None,
+                                        'id': 'UmVwb3J0OjE=',
+                                        'otherParticipants': 'Saruman',
+                                        'ourParticipants': 'Frodo, Gandalf',
+                                        'providedBenefit': '',
+                                        'published': '2018-01-02 00:00:00+00:00',
+                                        'receivedBenefit': 'The Ring',
+                                        'title': 'The Fellowship of the Ring'
+                                    }
+                                }
+                            ],
+                            'totalCount': 1
+                        }
+                    }
+                }
+            ]
+        }
+    }
+}
diff --git a/tests/schema/snapshots/snap_test_login_shortcuts.py b/tests/schema/snapshots/snap_test_login_shortcuts.py
new file mode 100644
index 0000000000000000000000000000000000000000..45cc81f6b986dea6fce412ac8a51a807bc57c7ad
--- /dev/null
+++ b/tests/schema/snapshots/snap_test_login_shortcuts.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_returns_only_shortcuts 1'] = {
+    'data': {
+        'loginShortcuts': [
+            {
+                'id': 'TG9naW5TaG9ydGN1dDoyMA==',
+                'name': 'bar'
+            }
+        ]
+    }
+}
+
+snapshots['test_none 1'] = {
+    'data': {
+        'loginShortcuts': [
+        ]
+    }
+}
diff --git a/tests/schema/snapshots/snap_test_node.py b/tests/schema/snapshots/snap_test_node.py
new file mode 100644
index 0000000000000000000000000000000000000000..d2b7936a3346d1c9f3281455a745a5833b6ae853
--- /dev/null
+++ b/tests/schema/snapshots/snap_test_node.py
@@ -0,0 +1,82 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_login_shortcut 1'] = {
+    'data': {
+        'node': {
+            'id': 'TG9naW5TaG9ydGN1dDoxMA==',
+            'name': 'foo'
+        }
+    }
+}
+
+snapshots['test_author 1'] = {
+    'data': {
+        'node': {
+            'extra': '{"x": 1}',
+            'firstName': 'Winston',
+            'id': 'QXV0aG9yOjU=',
+            'lastName': 'Wolfe'
+        }
+    }
+}
+
+snapshots['test_author__returns_only_if_is_author 1'] = {
+    'data': {
+        'node': None
+    }
+}
+
+snapshots['test_report 1'] = {
+    'data': {
+        'node': {
+            'author': {
+                'extra': '{"movies": 1}',
+                'firstName': 'Winston',
+                'id': 'QXV0aG9yOjE=',
+                'lastName': 'Wolfe'
+            },
+            'body': 'Long story short: we got the Ring!',
+            'date': '2018-01-01 00:00:00+00:00',
+            'extra': None,
+            'id': 'UmVwb3J0OjE=',
+            'otherParticipants': 'Saruman',
+            'ourParticipants': 'Frodo, Gandalf',
+            'providedBenefit': '',
+            'published': '2018-01-02 00:00:00+00:00',
+            'receivedBenefit': 'The Ring',
+            'title': 'The Fellowship of the Ring'
+        }
+    }
+}
+
+snapshots['test_user__unauthorized 1'] = {
+    'data': {
+        'node': None
+    }
+}
+
+snapshots['test_user__not_a_viewer 1'] = {
+    'data': {
+        'node': None
+    }
+}
+
+snapshots['test_user 1'] = {
+    'data': {
+        'node': {
+            'extra': '{"e": "mc2"}',
+            'firstName': 'Albert',
+            'id': 'VXNlcjo4',
+            'isAuthor': False,
+            'lastName': 'Einstein',
+            'openidUid': 'albert@einstein.id'
+        }
+    }
+}
diff --git a/tests/schema/snapshots/snap_test_search_reports.py b/tests/schema/snapshots/snap_test_search_reports.py
new file mode 100644
index 0000000000000000000000000000000000000000..70f33adda263e829d0e417f46a2e563d2f827ee3
--- /dev/null
+++ b/tests/schema/snapshots/snap_test_search_reports.py
@@ -0,0 +1,256 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_all 1'] = {
+    'data': {
+        'searchReports': {
+            'edges': [
+                {
+                    'cursor': 'MQ==',
+                    'node': {
+                        'author': {
+                            'extra': None,
+                            'firstName': 'Spongebob',
+                            'id': 'QXV0aG9yOjI=',
+                            'lastName': 'Squarepants'
+                        },
+                        'body': 'Another long story.',
+                        'date': '2018-01-05 00:00:00+00:00',
+                        'extra': '{"rings": 1}',
+                        'id': 'UmVwb3J0OjI=',
+                        'otherParticipants': 'Saruman, Sauron',
+                        'ourParticipants': 'Frodo, Gimli, Legolas',
+                        'providedBenefit': '',
+                        'published': '2018-01-10 00:00:00+00:00',
+                        'receivedBenefit': 'Mithrill Jacket',
+                        'title': 'The Two Towers'
+                    }
+                },
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'author': {
+                            'extra': '{"movies": 1}',
+                            'firstName': 'Winston',
+                            'id': 'QXV0aG9yOjE=',
+                            'lastName': 'Wolfe'
+                        },
+                        'body': 'Aragorn is the King. And we have lost the Ring.',
+                        'date': '2018-01-07 00:00:00+00:00',
+                        'extra': None,
+                        'id': 'UmVwb3J0OjM=',
+                        'otherParticipants': 'Sauron',
+                        'ourParticipants': 'Aragorn',
+                        'providedBenefit': 'The Ring',
+                        'published': '2018-01-08 00:00:00+00:00',
+                        'receivedBenefit': '',
+                        'title': 'The Return of the King'
+                    }
+                },
+                {
+                    'cursor': 'Mw==',
+                    'node': {
+                        'author': {
+                            'extra': '{"movies": 1}',
+                            'firstName': 'Winston',
+                            'id': 'QXV0aG9yOjE=',
+                            'lastName': 'Wolfe'
+                        },
+                        'body': 'Long story short: we got the Ring!',
+                        'date': '2018-01-01 00:00:00+00:00',
+                        'extra': None,
+                        'id': 'UmVwb3J0OjE=',
+                        'otherParticipants': 'Saruman',
+                        'ourParticipants': 'Frodo, Gandalf',
+                        'providedBenefit': '',
+                        'published': '2018-01-02 00:00:00+00:00',
+                        'receivedBenefit': 'The Ring',
+                        'title': 'The Fellowship of the Ring'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'Mw==',
+                'hasNextPage': False,
+                'hasPreviousPage': False,
+                'startCursor': 'MQ=='
+            },
+            'totalCount': 3
+        }
+    }
+}
+
+snapshots['test_query 1'] = {
+    'data': {
+        'searchReports': {
+            'edges': [
+                {
+                    'cursor': 'MQ==',
+                    'node': {
+                        'author': {
+                            'extra': None,
+                            'firstName': 'Spongebob',
+                            'id': 'QXV0aG9yOjI=',
+                            'lastName': 'Squarepants'
+                        },
+                        'body': 'Another long story.',
+                        'date': '2018-01-05 00:00:00+00:00',
+                        'extra': '{"rings": 1}',
+                        'id': 'UmVwb3J0OjI=',
+                        'otherParticipants': 'Saruman, Sauron',
+                        'ourParticipants': 'Frodo, Gimli, Legolas',
+                        'providedBenefit': '',
+                        'published': '2018-01-10 00:00:00+00:00',
+                        'receivedBenefit': 'Mithrill Jacket',
+                        'title': 'The Two Towers'
+                    }
+                }
+            ],
+            'totalCount': 1
+        }
+    }
+}
+
+snapshots['test_highlight 1'] = {
+    'data': {
+        'searchReports': {
+            'edges': [
+                {
+                    'cursor': 'MQ==',
+                    'node': {
+                        'author': {
+                            'extra': '{"movies": 1}',
+                            'firstName': 'Winston',
+                            'id': 'QXV0aG9yOjE=',
+                            'lastName': 'Wolfe'
+                        },
+                        'body': 'Aragorn is the King. And we have lost the <mark>Ring</mark>.',
+                        'date': '2018-01-07 00:00:00+00:00',
+                        'extra': None,
+                        'id': 'UmVwb3J0OjM=',
+                        'otherParticipants': 'Sauron',
+                        'ourParticipants': 'Aragorn',
+                        'providedBenefit': 'The <mark>Ring</mark>',
+                        'published': '2018-01-08 00:00:00+00:00',
+                        'receivedBenefit': '',
+                        'title': 'The Return of the King'
+                    }
+                },
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'author': {
+                            'extra': '{"movies": 1}',
+                            'firstName': 'Winston',
+                            'id': 'QXV0aG9yOjE=',
+                            'lastName': 'Wolfe'
+                        },
+                        'body': 'Long story short: we got the <mark>Ring</mark>!',
+                        'date': '2018-01-01 00:00:00+00:00',
+                        'extra': None,
+                        'id': 'UmVwb3J0OjE=',
+                        'otherParticipants': 'Saruman',
+                        'ourParticipants': 'Frodo, Gandalf',
+                        'providedBenefit': '',
+                        'published': '2018-01-02 00:00:00+00:00',
+                        'receivedBenefit': 'The <mark>Ring</mark>',
+                        'title': 'The Fellowship of the <mark>Ring</mark>'
+                    }
+                }
+            ],
+            'totalCount': 2
+        }
+    }
+}
+
+snapshots['test_first 1'] = {
+    'data': {
+        'searchReports': {
+            'edges': [
+                {
+                    'cursor': 'MQ==',
+                    'node': {
+                        'id': 'UmVwb3J0OjI=',
+                        'title': 'The Two Towers'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'MQ==',
+                'hasNextPage': True,
+                'hasPreviousPage': False,
+                'startCursor': 'MQ=='
+            },
+            'totalCount': 3
+        }
+    }
+}
+
+snapshots['test_first_after 1'] = {
+    'data': {
+        'searchReports': {
+            'edges': [
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'id': 'UmVwb3J0OjM=',
+                        'title': 'The Return of the King'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'Mg==',
+                'hasNextPage': True,
+                'hasPreviousPage': True,
+                'startCursor': 'Mg=='
+            },
+            'totalCount': 3
+        }
+    }
+}
+
+snapshots['test_last 1'] = {
+    'data': {
+        'searchReports': None
+    },
+    'errors': [
+        {
+            'locations': [
+                {
+                    'column': 9,
+                    'line': 3
+                }
+            ],
+            'message': 'Pagination "last" works only in combination with "before" argument.'
+        }
+    ]
+}
+
+snapshots['test_last_before 1'] = {
+    'data': {
+        'searchReports': {
+            'edges': [
+                {
+                    'cursor': 'Mg==',
+                    'node': {
+                        'id': 'UmVwb3J0OjM=',
+                        'title': 'The Return of the King'
+                    }
+                }
+            ],
+            'pageInfo': {
+                'endCursor': 'Mg==',
+                'hasNextPage': True,
+                'hasPreviousPage': True,
+                'startCursor': 'Mg=='
+            },
+            'totalCount': 3
+        }
+    }
+}
diff --git a/tests/schema/snapshots/snap_test_viewer.py b/tests/schema/snapshots/snap_test_viewer.py
new file mode 100644
index 0000000000000000000000000000000000000000..947e074adf4a51eafab207cdbfb2dfc7672b131b
--- /dev/null
+++ b/tests/schema/snapshots/snap_test_viewer.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_unauthenticated 1'] = {
+    'data': {
+        'viewer': None
+    }
+}
+
+snapshots['test_authenticated 1'] = {
+    'data': {
+        'viewer': {
+            'email': 'winston@wolfe.com',
+            'extra': '{"caliber": 45}',
+            'firstName': 'Winston',
+            'id': 'VXNlcjox',
+            'isAuthor': True,
+            'lastName': 'Wolfe',
+            'openidUid': 'TheWolf'
+        }
+    }
+}
+
+snapshots['test_wrong_header 1'] = {
+    'errors': [
+        {
+            'message': 'Wrong Authorization header. Expected: "Bearer <token>"'
+        }
+    ]
+}
+
+snapshots['test_wrong_token 1'] = {
+    'errors': [
+        {
+            'message': 'Invalid Token.'
+        }
+    ]
+}
+
+snapshots['test_unknown_user 1'] = {
+    'data': {
+        'viewer': None
+    }
+}
diff --git a/tests/schema/test_authors.py b/tests/schema/test_authors.py
new file mode 100644
index 0000000000000000000000000000000000000000..e81d03e9e50f0705766b85951359fe7a707ff352
--- /dev/null
+++ b/tests/schema/test_authors.py
@@ -0,0 +1,176 @@
+import pytest
+
+from openlobby.core.models import User
+
+from ..dummy import prepare_authors, prepare_report
+
+
+pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')]
+
+
+def test_all(client, snapshot):
+    prepare_authors()
+    User.objects.create(id=4, is_author=False, username='x')
+    res = client.post('/graphql', {'query': """
+    query {
+        authors {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    firstName
+                    lastName
+                    extra
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_first(client, snapshot):
+    prepare_authors()
+    res = client.post('/graphql', {'query': """
+    query {
+        authors (first: 2) {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    firstName
+                    lastName
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_first_after(client, snapshot):
+    prepare_authors()
+    res = client.post('/graphql', {'query': """
+    query {
+        authors (first: 1, after: "MQ==") {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    firstName
+                    lastName
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_last(client, snapshot):
+    prepare_authors()
+    res = client.post('/graphql', {'query': """
+    query {
+        authors (last: 2) {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    firstName
+                    lastName
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_last_before(client, snapshot):
+    prepare_authors()
+    res = client.post('/graphql', {'query': """
+    query {
+        authors (last: 1, before: "Mw==") {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    firstName
+                    lastName
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_with_reports(client, snapshot):
+    prepare_report()
+    res = client.post('/graphql', {'query': """
+    query {
+        authors {
+            edges {
+                node {
+                    id
+                    firstName
+                    lastName
+                    reports {
+                        totalCount
+                        edges {
+                            cursor
+                            node {
+                                id
+                                date
+                                published
+                                title
+                                body
+                                receivedBenefit
+                                providedBenefit
+                                ourParticipants
+                                otherParticipants
+                                extra
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
diff --git a/tests/schema/test_login_shortcuts.py b/tests/schema/test_login_shortcuts.py
new file mode 100644
index 0000000000000000000000000000000000000000..92f44fc4845c1429d155d2219bba100baf02166a
--- /dev/null
+++ b/tests/schema/test_login_shortcuts.py
@@ -0,0 +1,33 @@
+import pytest
+
+from openlobby.core.models import OpenIdClient
+
+
+pytestmark = pytest.mark.django_db
+
+
+def test_returns_only_shortcuts(client, snapshot):
+    OpenIdClient.objects.create(id=10, name='foo', issuer='foo')
+    OpenIdClient.objects.create(id=20, name='bar', issuer='bar', is_shortcut=True)
+    res = client.post('/graphql', {'query': """
+    query {
+        loginShortcuts {
+            id
+            name
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_none(client, snapshot):
+    OpenIdClient.objects.create(id=10, name='foo')
+    res = client.post('/graphql', {'query': """
+    query {
+        loginShortcuts {
+            id
+            name
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
diff --git a/tests/schema/test_node.py b/tests/schema/test_node.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b2adb7f818672f6fff5a3361e147ed1afa684b9
--- /dev/null
+++ b/tests/schema/test_node.py
@@ -0,0 +1,156 @@
+import pytest
+from graphql_relay import to_global_id
+
+from openlobby.core.auth import create_access_token
+from openlobby.core.models import OpenIdClient, User
+
+from ..dummy import prepare_report
+
+
+pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')]
+
+
+def test_login_shortcut(client, snapshot):
+    OpenIdClient.objects.create(id=10, name='foo', issuer='foo', is_shortcut=True)
+    res = client.post('/graphql', {'query': """
+    query {{
+        node (id:"{id}") {{
+            ... on LoginShortcut {{
+                id
+                name
+            }}
+        }}
+    }}
+    """.format(id=to_global_id('LoginShortcut', 10))})
+    snapshot.assert_match(res.json())
+
+
+def test_author(client, snapshot):
+    User.objects.create(
+        id=5,
+        is_author=True,
+        openid_uid='TheWolf',
+        first_name='Winston',
+        last_name='Wolfe',
+        extra={'x': 1},
+    )
+    res = client.post('/graphql', {'query': """
+    query {{
+        node (id:"{id}") {{
+            ... on Author {{
+                id
+                firstName
+                lastName
+                extra
+            }}
+        }}
+    }}
+    """.format(id=to_global_id('Author', 5))})
+    snapshot.assert_match(res.json())
+
+
+def test_author__returns_only_if_is_author(client, snapshot):
+    User.objects.create(id=7, is_author=False)
+    res = client.post('/graphql', {'query': """
+    query {{
+        node (id:"{id}") {{
+            ... on Author {{
+                id
+            }}
+        }}
+    }}
+    """.format(id=to_global_id('Author', 7))})
+    snapshot.assert_match(res.json())
+
+
+def test_report(client, snapshot):
+    prepare_report()
+    res = client.post('/graphql', {'query': """
+    query {{
+        node (id:"{id}") {{
+            ... on Report {{
+                id
+                date
+                published
+                title
+                body
+                receivedBenefit
+                providedBenefit
+                ourParticipants
+                otherParticipants
+                extra
+                author {{
+                    id
+                    firstName
+                    lastName
+                    extra
+                }}
+            }}
+        }}
+    }}
+    """.format(id=to_global_id('Report', 1))})
+    snapshot.assert_match(res.json())
+
+
+def test_user__unauthorized(client, snapshot):
+    User.objects.create(id=8, username='albert', openid_uid='albert@einstein.id',
+        first_name='Albert', last_name='Einstein', extra={'e': 'mc2'})
+    res = client.post('/graphql', {'query': """
+    query {{
+        node (id:"{id}") {{
+            ... on User {{
+                id
+                firstName
+                lastName
+                openidUid
+                isAuthor
+                extra
+            }}
+        }}
+    }}
+    """.format(id=to_global_id('User', 8))})
+    snapshot.assert_match(res.json())
+
+
+def test_user__not_a_viewer(client, snapshot):
+    User.objects.create(id=8, username='albert', openid_uid='albert@einstein.id',
+        first_name='Albert', last_name='Einstein', extra={'e': 'mc2'})
+    User.objects.create(id=2, username='isaac', openid_uid='isaac@newton.id',
+        first_name='Isaac', last_name='Newton', extra={'apple': 'hit'})
+    auth_header = 'Bearer {}'.format(create_access_token('isaac'))
+    res = client.post('/graphql', {'query': """
+    query {{
+        node (id:"{id}") {{
+            ... on User {{
+                id
+                firstName
+                lastName
+                openidUid
+                isAuthor
+                extra
+            }}
+        }}
+    }}
+    """.format(id=to_global_id('User', 8))}, HTTP_AUTHORIZATION=auth_header)
+    snapshot.assert_match(res.json())
+
+
+def test_user(client, snapshot):
+    User.objects.create(id=8, username='albert', openid_uid='albert@einstein.id',
+            first_name='Albert', last_name='Einstein', extra={'e': 'mc2'})
+    auth_header = 'Bearer {}'.format(create_access_token('albert'))
+    res = client.post('/graphql', {'query': """
+    query {{
+        node (id:"{id}") {{
+            ... on User {{
+                id
+                firstName
+                lastName
+                openidUid
+                isAuthor
+                extra
+            }}
+        }}
+    }}
+    """.format(id=to_global_id('User', 8))}, HTTP_AUTHORIZATION=auth_header)
+    snapshot.assert_match(res.json())
diff --git a/tests/schema/test_search_reports.py b/tests/schema/test_search_reports.py
new file mode 100644
index 0000000000000000000000000000000000000000..a980aa37a4a31eb166716a9d9b1a486d61704be5
--- /dev/null
+++ b/tests/schema/test_search_reports.py
@@ -0,0 +1,211 @@
+import pytest
+
+from ..dummy import prepare_reports
+
+
+pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')]
+
+
+def test_all(client, snapshot):
+    prepare_reports()
+    res = client.post('/graphql', {'query': """
+    query {
+        searchReports {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    date
+                    published
+                    title
+                    body
+                    receivedBenefit
+                    providedBenefit
+                    ourParticipants
+                    otherParticipants
+                    extra
+                    author {
+                        id
+                        firstName
+                        lastName
+                        extra
+                    }
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_query(client, snapshot):
+    prepare_reports()
+    res = client.post('/graphql', {'query': """
+    query {
+        searchReports (query: "towers") {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    date
+                    published
+                    title
+                    body
+                    receivedBenefit
+                    providedBenefit
+                    ourParticipants
+                    otherParticipants
+                    extra
+                    author {
+                        id
+                        firstName
+                        lastName
+                        extra
+                    }
+                }
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_highlight(client, snapshot):
+    prepare_reports()
+    res = client.post('/graphql', {'query': """
+    query {
+        searchReports (query: "Ring", highlight: true) {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    date
+                    published
+                    title
+                    body
+                    receivedBenefit
+                    providedBenefit
+                    ourParticipants
+                    otherParticipants
+                    extra
+                    author {
+                        id
+                        firstName
+                        lastName
+                        extra
+                    }
+                }
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_first(client, snapshot):
+    prepare_reports()
+    res = client.post('/graphql', {'query': """
+    query {
+        searchReports (first: 1) {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    title
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_first_after(client, snapshot):
+    prepare_reports()
+    res = client.post('/graphql', {'query': """
+    query {
+        searchReports (first: 1, after: "MQ==") {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    title
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_last(client, snapshot):
+    prepare_reports()
+    res = client.post('/graphql', {'query': """
+    query {
+        searchReports (last: 2) {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    title
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_last_before(client, snapshot):
+    prepare_reports()
+    res = client.post('/graphql', {'query': """
+    query {
+        searchReports (last: 1, before: "Mw==") {
+            totalCount
+            edges {
+                cursor
+                node {
+                    id
+                    title
+                }
+            }
+            pageInfo {
+                hasPreviousPage
+                hasNextPage
+                startCursor
+                endCursor
+            }
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
diff --git a/tests/schema/test_viewer.py b/tests/schema/test_viewer.py
new file mode 100644
index 0000000000000000000000000000000000000000..4ab66d2c54d60bf97fc47ea0d169bf855b85dd3b
--- /dev/null
+++ b/tests/schema/test_viewer.py
@@ -0,0 +1,85 @@
+import pytest
+
+from openlobby.core.auth import create_access_token
+from openlobby.core.models import User
+
+
+pytestmark = pytest.mark.django_db
+
+
+@pytest.fixture(autouse=True)
+def setup():
+    User.objects.create(id=1, is_author=True, username='wolfe', openid_uid='TheWolf',
+        first_name='Winston', last_name='Wolfe', email='winston@wolfe.com',
+        extra={'caliber': 45})
+
+
+def test_unauthenticated(client, snapshot):
+    res = client.post('/graphql', {'query': """
+    query {
+        viewer {
+            id
+        }
+    }
+    """})
+    snapshot.assert_match(res.json())
+
+
+def test_authenticated(client, snapshot):
+    token = create_access_token('wolfe')
+    auth_header = 'Bearer {}'.format(token)
+    res = client.post('/graphql', {'query': """
+    query {
+        viewer {
+            id
+            firstName
+            lastName
+            email
+            openidUid
+            isAuthor
+            extra
+        }
+    }
+    """}, HTTP_AUTHORIZATION=auth_header)
+    snapshot.assert_match(res.json())
+
+
+# integration tests of wrong authentication
+
+def test_wrong_header(client, snapshot):
+    token = create_access_token('wolfe')
+    auth_header = 'WRONG {}'.format(token)
+    res = client.post('/graphql', {'query': """
+    query {
+        viewer {
+            id
+        }
+    }
+    """}, HTTP_AUTHORIZATION=auth_header)
+    snapshot.assert_match(res.json())
+
+
+def test_wrong_token(client, snapshot):
+    token = create_access_token('wolfe')
+    auth_header = 'Bearer XXX{}'.format(token)
+    res = client.post('/graphql', {'query': """
+    query {
+        viewer {
+            id
+        }
+    }
+    """}, HTTP_AUTHORIZATION=auth_header)
+    snapshot.assert_match(res.json())
+
+
+def test_unknown_user(client, snapshot):
+    token = create_access_token('unknown')
+    auth_header = 'Bearer {}'.format(token)
+    res = client.post('/graphql', {'query': """
+    query {
+        viewer {
+            id
+        }
+    }
+    """}, HTTP_AUTHORIZATION=auth_header)
+    snapshot.assert_match(res.json())
diff --git a/tests/snapshots/snap_test_management.py b/tests/snapshots/snap_test_management.py
index 28674d9096c3c1966df693b351fdd87c71015203..ff10ca76366130dd48abf3d4828fa3acb96a314b 100644
--- a/tests/snapshots/snap_test_management.py
+++ b/tests/snapshots/snap_test_management.py
@@ -44,59 +44,6 @@ snapshots['test_create_index__check_analysis_settings 1'] = {
 
 snapshots['test_create_index__check_mappings 1'] = {
     'mappings': {
-        'login-attempt': {
-            'properties': {
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'expiration': {
-                    'type': 'integer'
-                },
-                'nonce': {
-                    'type': 'keyword'
-                },
-                'openid_uid': {
-                    'type': 'keyword'
-                },
-                'redirect_uri': {
-                    'type': 'keyword'
-                },
-                'state': {
-                    'type': 'keyword'
-                }
-            }
-        },
-        'open-id-client': {
-            'properties': {
-                'authorization_endpoint': {
-                    'type': 'keyword'
-                },
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'is_shortcut': {
-                    'type': 'boolean'
-                },
-                'issuer': {
-                    'type': 'keyword'
-                },
-                'name_x': {
-                    'type': 'keyword'
-                },
-                'token_endpoint': {
-                    'type': 'keyword'
-                },
-                'userinfo_endpoint': {
-                    'type': 'keyword'
-                }
-            }
-        },
         'report': {
             'properties': {
                 'author_id': {
@@ -169,59 +116,6 @@ snapshots['test_create_index__check_mappings 1'] = {
 
 snapshots['test_init_alias 1'] = {
     'mappings': {
-        'login-attempt': {
-            'properties': {
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'expiration': {
-                    'type': 'integer'
-                },
-                'nonce': {
-                    'type': 'keyword'
-                },
-                'openid_uid': {
-                    'type': 'keyword'
-                },
-                'redirect_uri': {
-                    'type': 'keyword'
-                },
-                'state': {
-                    'type': 'keyword'
-                }
-            }
-        },
-        'open-id-client': {
-            'properties': {
-                'authorization_endpoint': {
-                    'type': 'keyword'
-                },
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'is_shortcut': {
-                    'type': 'boolean'
-                },
-                'issuer': {
-                    'type': 'keyword'
-                },
-                'name_x': {
-                    'type': 'keyword'
-                },
-                'token_endpoint': {
-                    'type': 'keyword'
-                },
-                'userinfo_endpoint': {
-                    'type': 'keyword'
-                }
-            }
-        },
         'report': {
             'properties': {
                 'author_id': {
@@ -294,59 +188,6 @@ snapshots['test_init_alias 1'] = {
 
 snapshots['test_reindex__check_new_index 1'] = {
     'mappings': {
-        'login-attempt': {
-            'properties': {
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'expiration': {
-                    'type': 'integer'
-                },
-                'nonce': {
-                    'type': 'keyword'
-                },
-                'openid_uid': {
-                    'type': 'keyword'
-                },
-                'redirect_uri': {
-                    'type': 'keyword'
-                },
-                'state': {
-                    'type': 'keyword'
-                }
-            }
-        },
-        'open-id-client': {
-            'properties': {
-                'authorization_endpoint': {
-                    'type': 'keyword'
-                },
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'is_shortcut': {
-                    'type': 'boolean'
-                },
-                'issuer': {
-                    'type': 'keyword'
-                },
-                'name_x': {
-                    'type': 'keyword'
-                },
-                'token_endpoint': {
-                    'type': 'keyword'
-                },
-                'userinfo_endpoint': {
-                    'type': 'keyword'
-                }
-            }
-        },
         'report': {
             'properties': {
                 'author_id': {
@@ -419,59 +260,6 @@ snapshots['test_reindex__check_new_index 1'] = {
 
 snapshots['test_init_documents 1'] = {
     'mappings': {
-        'login-attempt': {
-            'properties': {
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'expiration': {
-                    'type': 'integer'
-                },
-                'nonce': {
-                    'type': 'keyword'
-                },
-                'openid_uid': {
-                    'type': 'keyword'
-                },
-                'redirect_uri': {
-                    'type': 'keyword'
-                },
-                'state': {
-                    'type': 'keyword'
-                }
-            }
-        },
-        'open-id-client': {
-            'properties': {
-                'authorization_endpoint': {
-                    'type': 'keyword'
-                },
-                'client_id': {
-                    'type': 'keyword'
-                },
-                'client_secret': {
-                    'type': 'keyword'
-                },
-                'is_shortcut': {
-                    'type': 'boolean'
-                },
-                'issuer': {
-                    'type': 'keyword'
-                },
-                'name_x': {
-                    'type': 'keyword'
-                },
-                'token_endpoint': {
-                    'type': 'keyword'
-                },
-                'userinfo_endpoint': {
-                    'type': 'keyword'
-                }
-            }
-        },
         'report': {
             'properties': {
                 'author_id': {
diff --git a/tests/snapshots/snap_test_middleware.py b/tests/snapshots/snap_test_middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..2be80f56b2e39b8ef8bb4d16440d117633067ad3
--- /dev/null
+++ b/tests/snapshots/snap_test_middleware.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot
+
+
+snapshots = Snapshot()
+
+snapshots['test_wrong_header 1'] = {
+    'errors': [
+        {
+            'message': 'Wrong Authorization header. Expected: "Bearer <token>"'
+        }
+    ]
+}
+
+snapshots['test_invalid_token 1'] = {
+    'errors': [
+        {
+            'message': 'Invalid Token.'
+        }
+    ]
+}
diff --git a/tests/snapshots/snap_test_models.py b/tests/snapshots/snap_test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..61f5a0a38292a80edc46aa69a4e27d7342a62a3f
--- /dev/null
+++ b/tests/snapshots/snap_test_models.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# snapshottest: v1 - https://goo.gl/zC4yUc
+from __future__ import unicode_literals
+
+from snapshottest import Snapshot, GenericRepr
+
+
+snapshots = Snapshot()
+
+snapshots['test_report_is_saved_in_elasticsearch 1'] = [
+    GenericRepr("ReportDoc(index='report_ded_test', doc_type='report_doc', id='2')")
+]
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 51d1e97b109d8dd8c59cd0d1af07bacd0cfbb94f..580bcf807c08a23d34346ba608f186b3993d5429 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -1,54 +1,34 @@
+from django.conf import settings
 import time
 import jwt
 import pytest
 
-from openlobby.settings import (
-    SECRET_KEY,
-    JWT_ALGORITHM,
-    LOGIN_ATTEMPT_EXPIRATION,
-    SESSION_EXPIRATION,
-)
-from openlobby.auth import (
-    get_login_attempt_expiration_time,
-    get_session_expiration_time,
+from openlobby.core.auth import (
     create_access_token,
     parse_access_token,
 )
 
 
-def test_get_login_attempt_expiration_time():
-    expiration = get_login_attempt_expiration_time()
-    expected = int(time.time() + LOGIN_ATTEMPT_EXPIRATION)
-    assert expected <= expiration <= expected + 1
-
-
-def test_get_session_expiration_time():
-    expiration = get_session_expiration_time()
-    expected = int(time.time() + SESSION_EXPIRATION)
-    assert expected <= expiration <= expected + 1
-
-
 def test_create_access_token():
-    session_id = 'idkfa'
-    expiration = int(time.time() + 10)
-    token = create_access_token(session_id, expiration)
-    payload = jwt.decode(token, SECRET_KEY, algorithms=[JWT_ALGORITHM])
+    username = 'idkfa'
+    token = create_access_token(username)
+    payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
     assert isinstance(token, str)
-    assert payload['sub'] == session_id
-    assert payload['exp'] == expiration
+    assert payload['sub'] == username
+    expected_expiration = int(time.time() + settings.SESSION_EXPIRATION)
+    assert expected_expiration <= payload['exp'] <= expected_expiration + 1
 
 
 def test_parse_access_token():
-    session_id = 'iddqd'
-    expiration = int(time.time() + 10)
-    token = create_access_token(session_id, expiration)
+    username = 'iddqd'
+    token = create_access_token(username)
     result = parse_access_token(token)
-    assert result == session_id
+    assert result == username
 
 
 def test_parse_access_token__expired():
-    session_id = 'idfa'
+    username = 'idfa'
     expiration = int(time.time() - 1)
-    token = create_access_token(session_id, expiration)
+    token = create_access_token(username, expiration)
     with pytest.raises(jwt.ExpiredSignatureError):
         parse_access_token(token)
diff --git a/tests/test_documents.py b/tests/test_documents.py
deleted file mode 100644
index 630dd9397315d2b85593f57ed34d6d30055c8850..0000000000000000000000000000000000000000
--- a/tests/test_documents.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import time
-
-from openlobby.documents import SessionDoc, UserDoc
-
-
-class TestSessionDoc:
-
-    def test_get_active__does_not_exists(self, es, index):
-        session = SessionDoc.get_active('foo', es=es, index=index)
-        assert session is None
-
-    def test_get_active__expired(self, es, index):
-        s = SessionDoc(user_id='foo', expiration=123456789)
-        s.save(using=es, index=index)
-        session = SessionDoc.get_active(s.meta.id, es=es, index=index)
-        assert session is None
-
-    def test_get_active(self, es, index):
-        expiration = time.time() + 100
-        s = SessionDoc(user_id='foo', expiration=expiration)
-        s.save(using=es, index=index, refresh='true')
-        session = SessionDoc.get_active(s.meta.id, es=es, index=index)
-        assert session is not None
-        assert session.user_id == 'foo'
-        assert session.expiration == expiration
-
-
-class TestUserDoc:
-
-    def test_get_by_openid_uid__does_not_exists(self, es, index):
-        user = UserDoc.get_by_openid_uid('foo', es=es, index=index)
-        assert user is None
-
-    def test_get_by_openid_uid(self, es, index):
-        for uid in ['foo', 'bar', 'baz']:
-            u = UserDoc(openid_uid=uid)
-            u.save(using=es, index=index, refresh='true')
-        user = UserDoc.get_by_openid_uid('bar', es=es, index=index)
-        assert user is not None
-        assert user.openid_uid == 'bar'
diff --git a/tests/test_management.py b/tests/test_management.py
deleted file mode 100644
index ef1c7389c373ac633f472920e7105c26ebf90ea3..0000000000000000000000000000000000000000
--- a/tests/test_management.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import pytest
-
-from openlobby.management import (
-    AliasAlreadyExistsError,
-    IndexAlreadyExistsError,
-    create_index,
-    get_new_index_version,
-    init_alias,
-    init_documents,
-    reindex,
-)
-from openlobby.documents import UserDoc
-
-
-def test_create_index(es, index_name):
-    create_index(es, index_name)
-    assert es.indices.exists(index_name)
-
-
-def test_create_index__already_exists(es, index_name):
-    assert es.indices.create(index_name)
-    with pytest.raises(IndexAlreadyExistsError):
-        create_index(es, index_name)
-
-
-def test_init_documents(es, index_name, snapshot):
-    assert es.indices.create(index_name)
-    init_documents(es, index_name)
-    mappings = es.indices.get_mapping(index_name)
-    snapshot.assert_match(mappings[index_name])
-
-
-def test_create_index__check_analysis_settings(es, index_name, snapshot):
-    create_index(es, index_name)
-    settings = es.indices.get_settings(index=index_name)
-    snapshot.assert_match(settings[index_name]['settings']['index']['analysis'])
-
-
-def test_create_index__check_mappings(es, index_name, snapshot):
-    create_index(es, index_name)
-    mappings = es.indices.get_mapping(index_name)
-    snapshot.assert_match(mappings[index_name])
-
-
-@pytest.mark.parametrize('old, new', [
-    ('foo_v1', 'foo_v2'),
-    ('bar_v9', 'bar_v10'),
-    ('two_words_v5', 'two_words_v6'),
-])
-def test_get_new_index_version(old, new):
-    assert get_new_index_version(old) == new
-
-
-def test_get_new_index_version__wrong_name():
-    with pytest.raises(ValueError):
-        get_new_index_version('foo')
-
-
-def test_init_alias(es, index_name, snapshot):
-    alias = index_name
-    init_alias(es, alias)
-    index = '{}_v1'.format(alias)
-    assert es.indices.exists(alias)
-    assert es.indices.exists(index)
-    mappings = es.indices.get_mapping(index)
-    snapshot.assert_match(mappings[index])
-
-
-def test_init_alias__already_exists(es, index_name):
-    alias = index_name
-    init_alias(es, alias)
-    with pytest.raises(AliasAlreadyExistsError):
-        init_alias(es, alias)
-
-
-def test_reindex__check_new_index(es, index_name, snapshot):
-    alias = index_name
-    init_alias(es, alias)
-    reindex(es, alias)
-    new_index = '{}_v2'.format(alias)
-    assert es.indices.exists(new_index)
-    mappings = es.indices.get_mapping(new_index)
-    snapshot.assert_match(mappings[new_index])
-
-
-def test_reindex__check_alias(es, index_name):
-    alias = index_name
-    init_alias(es, alias)
-    reindex(es, alias)
-    new_index = '{}_v2'.format(alias)
-    assert es.indices.exists(alias)
-    res = es.indices.get_alias(name=alias)
-    assert res == {new_index: {'aliases': {alias: {}}}}
-
-
-def test_reindex__with_some_data(es, index_name, snapshot):
-    alias = index_name
-    init_alias(es, alias)
-    user = UserDoc(name='The Black Knight')
-    user.save(using=es, index=alias, refresh='true')
-    reindex(es, alias)
-    new_user = UserDoc.get(id=user.meta.id, using=es, index=alias)
-    assert new_user.name == 'The Black Knight'
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..37dbd174bdf600ddee2fbb0b17ae53813e69c4a9
--- /dev/null
+++ b/tests/test_middleware.py
@@ -0,0 +1,77 @@
+import pytest
+import json
+from unittest.mock import Mock
+
+from openlobby.core.auth import create_access_token
+from openlobby.core.middleware import TokenAuthMiddleware
+from openlobby.core.models import User
+
+
+pytestmark = pytest.mark.django_db
+
+
+def test_no_auth_header():
+    request = Mock()
+    request.user = None
+    request.META.get.return_value = None
+
+    middleware = TokenAuthMiddleware(lambda r: r)
+    response = middleware(request)
+
+    request.META.get.assert_called_once_with('HTTP_AUTHORIZATION')
+    assert response == request
+    assert response.user is None
+
+
+def test_authorized_user():
+    user = User.objects.create(username='wolfe', first_name='Winston',
+        last_name='Wolfe', email='winston@wolfe.com')
+    request = Mock()
+    request.user = None
+    request.META.get.return_value = 'Bearer {}'.format(create_access_token('wolfe'))
+
+    middleware = TokenAuthMiddleware(lambda r: r)
+    response = middleware(request)
+
+    request.META.get.assert_called_once_with('HTTP_AUTHORIZATION')
+    assert response == request
+    assert response.user == user
+
+
+def test_wrong_header(snapshot):
+    request = Mock()
+    request.user = None
+    request.META.get.return_value = 'WRONG {}'.format(create_access_token('unknown'))
+
+    middleware = TokenAuthMiddleware(lambda r: r)
+    response = middleware(request)
+
+    request.META.get.assert_called_once_with('HTTP_AUTHORIZATION')
+    assert response.status_code == 400
+    snapshot.assert_match(json.loads(response.content))
+
+
+def test_invalid_token(snapshot):
+    request = Mock()
+    request.user = None
+    request.META.get.return_value = 'Bearer XXX{}'.format(create_access_token('unknown'))
+
+    middleware = TokenAuthMiddleware(lambda r: r)
+    response = middleware(request)
+
+    request.META.get.assert_called_once_with('HTTP_AUTHORIZATION')
+    assert response.status_code == 401
+    snapshot.assert_match(json.loads(response.content))
+
+
+def test_unknown_user(snapshot):
+    request = Mock()
+    request.user = None
+    request.META.get.return_value = 'Bearer {}'.format(create_access_token('unknown'))
+
+    middleware = TokenAuthMiddleware(lambda r: r)
+    response = middleware(request)
+
+    request.META.get.assert_called_once_with('HTTP_AUTHORIZATION')
+    assert response == request
+    assert response.user is None
diff --git a/tests/test_models.py b/tests/test_models.py
new file mode 100644
index 0000000000000000000000000000000000000000..9ef9ae1bf86de445b916f4cc919edf6e38161f30
--- /dev/null
+++ b/tests/test_models.py
@@ -0,0 +1,76 @@
+import pytest
+import arrow
+from django.conf import settings
+from unittest.mock import patch
+
+from openlobby.core.models import Report, User, OpenIdClient, LoginAttempt
+from openlobby.core.documents import ReportDoc
+
+
+pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')]
+
+
+def test_report__marks_user_as_author_on_save():
+    author = User.objects.create(id=1, is_author=False)
+    date = arrow.get(2018, 1, 1).datetime
+    Report.objects.create(author=author, date=date, body='Lorem ipsum.')
+    user = User.objects.get(id=1)
+    assert user.is_author
+
+
+def test_report__is_saved_in_elasticsearch():
+    author = User.objects.create(id=6)
+    date = arrow.get(2018, 1, 1).datetime
+    published = arrow.get(2018, 1, 2).datetime
+    Report.objects.create(
+        id=3,
+        author=author,
+        date=date,
+        published=published,
+        title='It happened',
+        body='Lorem ipsum.',
+        received_benefit='coffee',
+        provided_benefit='tea',
+        our_participants='me',
+        other_participants='them',
+        extra={'a': 3},
+    )
+    docs = list(ReportDoc.search())
+    assert len(docs) == 1
+    doc = docs[0]
+    assert doc.meta.id == '3'
+    assert doc.author_id == 6
+    assert doc.date == date
+    assert doc.published == published
+    assert doc.title == 'It happened'
+    assert doc.body == 'Lorem ipsum.'
+    assert doc.received_benefit == 'coffee'
+    assert doc.provided_benefit == 'tea'
+    assert doc.our_participants == 'me'
+    assert doc.other_participants == 'them'
+    assert doc.extra == {'a': 3}
+
+
+def test_report__save_works_with_no_extra():
+    author = User.objects.create(id=6)
+    date = arrow.get(2018, 1, 1).datetime
+    Report.objects.create(
+        id=7,
+        author=author,
+        date=date,
+        published=date,
+        body='Lorem ipsum.',
+    )
+    docs = list(ReportDoc.search())
+    assert len(docs) == 1
+    doc = docs[0]
+    assert doc.meta.id == '7'
+    assert doc.extra is None
+
+
+def test_login_attempt__default_expiration():
+    client = OpenIdClient.objects.create(name='a', client_id='b', client_secret='c')
+    with patch('openlobby.core.models.time.time', return_value=10000):
+        attempt = LoginAttempt.objects.create(openid_client=client, state='foo',
+            app_redirect_uri='http://openlobby/app')
+    assert attempt.expiration == 10000 + settings.LOGIN_ATTEMPT_EXPIRATION
diff --git a/tests/test_paginator.py b/tests/test_paginator.py
index 590788fd47051349da10dfd359f0472afa39f254..8a8d08c8fb823235a518ef05ddf7c8c27c056743 100644
--- a/tests/test_paginator.py
+++ b/tests/test_paginator.py
@@ -1,6 +1,12 @@
 import pytest
 
-from openlobby.paginator import PER_PAGE, encode_cursor, decode_cursor, Paginator
+from openlobby.core.api.paginator import (
+    PER_PAGE,
+    encode_cursor,
+    decode_cursor,
+    Paginator,
+    MissingBeforeValueError,
+)
 
 
 @pytest.mark.parametrize('num, cursor', [
@@ -41,10 +47,8 @@ def test_paginator__custom_per_page():
 @pytest.mark.parametrize('kw, slice_from, slice_to', [
     ({'first': 15}, 0, 15),
     ({'first': 5, 'after': encode_cursor(3)}, 3, 8),
-    ({'last': 3}, 7, 10),
     ({'last': 8, 'before': encode_cursor(20)}, 11, 19),
     # overflow of slice_from
-    ({'last': 15}, 0, 10),
     ({'last': 100, 'before': encode_cursor(42)}, 0, 41),
     # preffer first before last if both provided
     ({'first': 20, 'last': 4}, 0, 20),
@@ -55,6 +59,11 @@ def test_paginator__input_combinations(kw, slice_from, slice_to):
     assert paginator.slice_to == slice_to
 
 
+def test_paginator__last_without_before():
+    with pytest.raises(MissingBeforeValueError):
+        Paginator(last=1)
+
+
 @pytest.mark.parametrize('kw, total, previous, next, start, end', [
     ({}, 10, False, False, 1, 10),
     ({}, 15, False, True, 1, 10),
@@ -66,10 +75,6 @@ def test_paginator__input_combinations(kw, slice_from, slice_to):
     ({'first': 3, 'after': encode_cursor(7)}, 20, True, True, 8, 10),
     ({'first': 3, 'after': encode_cursor(17)}, 20, True, False, 18, 20),
     ({'first': 5, 'after': encode_cursor(17)}, 20, True, False, 18, 20),
-    ({'last': 1}, 10, True, False, 10, 10),
-    ({'last': 5}, 10, True, False, 6, 10),
-    ({'last': 15}, 10, False, False, 1, 10),
-    ({'last': 10}, 10, False, False, 1, 10),
     ({'last': 4, 'before': encode_cursor(10)}, 20, True, True, 6, 9),
     ({'last': 4, 'before': encode_cursor(5)}, 20, False, True, 1, 4),
     ({'last': 4, 'before': encode_cursor(3)}, 20, False, True, 1, 2),
@@ -89,7 +94,7 @@ def test_paginator__get_page_info(kw, total, previous, next, start, end):
 @pytest.mark.parametrize('kw, num, cursor', [
     ({}, 3, encode_cursor(3)),
     ({'first': 6, 'after': encode_cursor(1)}, 3, encode_cursor(4)),
-    ({'last': 6}, 3, encode_cursor(7)),
+    ({'last': 6, 'before': encode_cursor(11)}, 3, encode_cursor(7)),
 ])
 def test_paginator__get_edge_cursor(kw, num, cursor):
     paginator = Paginator(**kw)
diff --git a/tests/test_sanitizers.py b/tests/test_sanitizers.py
index 7ddf1cba7488af01b55a3109fc1577bb4537c4f7..74ed2722145b8b924253ebe6046baacd866eac90 100644
--- a/tests/test_sanitizers.py
+++ b/tests/test_sanitizers.py
@@ -1,6 +1,6 @@
 import pytest
 
-from openlobby.sanitizers import strip_all_tags, extract_text
+from openlobby.core.api.sanitizers import strip_all_tags, extract_text
 
 
 @pytest.mark.parametrize('input, text', [
diff --git a/tests/test_search.py b/tests/test_search.py
new file mode 100644
index 0000000000000000000000000000000000000000..a77314f3e9489b4725e3b570dd830cb570467400
--- /dev/null
+++ b/tests/test_search.py
@@ -0,0 +1,64 @@
+import pytest
+
+from openlobby.core.api.paginator import Paginator, encode_cursor
+from openlobby.core.search import query_reports, reports_by_author
+
+from .dummy import prepare_reports
+
+
+pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures('django_es')]
+
+
+@pytest.mark.parametrize('query, expected_ids', [
+    ('', [2, 3, 1]),
+    ('sauron', [2, 3]),
+    ('towers', [2]),
+    ('Aragorn Gandalf', [3, 1]),
+])
+def test_query_reports(query, expected_ids):
+    prepare_reports()
+    paginator = Paginator()
+    response = query_reports(query, paginator)
+    assert expected_ids == [int(r.meta.id) for r in response]
+
+
+def test_query_reports__highlight():
+    prepare_reports()
+    paginator = Paginator()
+    query = 'King'
+    response = query_reports(query, paginator, highlight=True)
+    doc = response.hits[0]
+    assert '<mark>King</mark>' in doc.meta.highlight.title[0]
+    assert '<mark>King</mark>' in doc.meta.highlight.body[0]
+
+
+@pytest.mark.parametrize('first, after, expected_ids', [
+    (2, None, [2, 3]),
+    (2, encode_cursor(1), [3, 1]),
+])
+def test_query_reports__pagination(first, after, expected_ids):
+    prepare_reports()
+    query = ''
+    paginator = Paginator(first=first, after=after)
+    response = query_reports(query, paginator)
+    assert expected_ids == [int(r.meta.id) for r in response]
+
+
+def test_reports_by_author():
+    prepare_reports()
+    author_id = 1
+    paginator = Paginator()
+    response = reports_by_author(author_id, paginator)
+    assert [3, 1] == [int(r.meta.id) for r in response]
+
+
+@pytest.mark.parametrize('first, after, expected_ids', [
+    (1, None, [3]),
+    (1, encode_cursor(1), [1]),
+])
+def test_reports_by_author__pagination(first, after, expected_ids):
+    prepare_reports()
+    author_id = 1
+    paginator = Paginator(first=first, after=after)
+    response = reports_by_author(author_id, paginator)
+    assert expected_ids == [int(r.meta.id) for r in response]
diff --git a/tests/test_views.py b/tests/test_views.py
new file mode 100644
index 0000000000000000000000000000000000000000..b67547d90cc05e2d2e095b1910bfce1cbdc4d2a0
--- /dev/null
+++ b/tests/test_views.py
@@ -0,0 +1,101 @@
+import pytest
+from django.urls import reverse
+from unittest.mock import patch
+import urllib.parse
+
+from openlobby.core.auth import parse_access_token
+from openlobby.core.models import User, OpenIdClient, LoginAttempt
+from openlobby.core.openid import register_client
+
+
+pytestmark = pytest.mark.django_db
+
+
+def test_login_redirect__new_user(client, issuer):
+    state = 'IDDQD'
+    sub = 'IDKFA'
+    openid_uid = 'elon.musk@openid'
+    first_name = 'Elon'
+    last_name = 'Musk'
+    email = 'elon.musk@tesla.com'
+    app_redirect_uri = 'http://doom.is.legend'
+
+    oc = register_client(issuer)
+    oid_client = OpenIdClient.objects.create(name='Test', issuer=issuer,
+        client_id=oc.client_id, client_secret=oc.client_secret)
+    LoginAttempt.objects.create(openid_client=oid_client, state=state,
+        app_redirect_uri=app_redirect_uri, openid_uid=openid_uid)
+
+    # we are not testing real redirect url params, so we mock communication with
+    # OpenID Provider
+    user_info = {
+        'sub': sub,
+        'given_name': first_name,
+        'family_name': last_name,
+        'email': email,
+    }
+    with patch('openlobby.core.views.get_user_info', return_value=user_info) as mock:
+        response = client.get(reverse('login-redirect'), {'state': state})
+        m_client, m_query_string = mock.call_args[0]
+        assert m_client.client_id == oc.client_id
+        assert m_query_string == 'state={}'.format(state)
+
+    assert response.status_code == 302
+    url, query_string = response.url.split('?')
+    qs = urllib.parse.parse_qs(query_string)
+    assert url == app_redirect_uri
+    assert sub == parse_access_token(qs['token'][0])
+
+    user = User.objects.get()
+    assert user.username == sub
+    assert user.first_name == first_name
+    assert user.last_name == last_name
+    assert user.email == email
+    assert user.openid_uid == openid_uid
+
+
+def test_login_redirect__existing_user(client, issuer):
+    state = 'IDDQD'
+    sub = 'IDKFA'
+    openid_uid = 'elon.musk@openid'
+    first_name = 'Elon'
+    last_name = 'Musk'
+    email = 'elon.musk@tesla.com'
+    app_redirect_uri = 'http://doom.is.legend'
+
+    oc = register_client(issuer)
+    oid_client = OpenIdClient.objects.create(name='Test', issuer=issuer,
+        client_id=oc.client_id, client_secret=oc.client_secret)
+    LoginAttempt.objects.create(openid_client=oid_client, state=state,
+        app_redirect_uri=app_redirect_uri, openid_uid=openid_uid)
+    User.objects.create(username=sub, first_name=first_name, last_name=last_name,
+        email=email, openid_uid=openid_uid)
+
+    # we are not testing real redirect url params, so we mock communication with
+    # OpenID Provider
+    user_info = {
+        'sub': sub,
+        # return different details
+        'given_name': 'Elons',
+        'family_name': 'Mustache',
+        'email': 'elons.mustache@spacex.com',
+    }
+    with patch('openlobby.core.views.get_user_info', return_value=user_info) as mock:
+        response = client.get(reverse('login-redirect'), {'state': state})
+        m_client, m_query_string = mock.call_args[0]
+        assert m_client.client_id == oc.client_id
+        assert m_query_string == 'state={}'.format(state)
+
+    assert response.status_code == 302
+    url, query_string = response.url.split('?')
+    qs = urllib.parse.parse_qs(query_string)
+    assert url == app_redirect_uri
+    assert sub == parse_access_token(qs['token'][0])
+
+    # check there is still just one user, who's details are not updated
+    user = User.objects.get()
+    assert user.username == sub
+    assert user.first_name == first_name
+    assert user.last_name == last_name
+    assert user.email == email
+    assert user.openid_uid == openid_uid