Skip to content
Snippets Groups Projects
Unverified Commit 179a0300 authored by jan.bednarik's avatar jan.bednarik Committed by GitHub
Browse files

Merge pull request #1 from openlobby/django

Refactoring to use Django and PostgreSQL as main DB
parents 26ff086d b3399fea
No related branches found
No related tags found
No related merge requests found
Showing
with 601 additions and 97 deletions
......@@ -3,3 +3,5 @@ __pycache__/
.env
.cache/
*.egg-info/
.coverage
.pytest_cache/
......@@ -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 .
......
......@@ -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).
......
#!/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)
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()
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
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()
......@@ -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,7 +31,9 @@ class Paginator:
slice_to = slice_from + first
elif last is not None:
if before is not None:
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:
......
File moved
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]
......@@ -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)
from django.http import JsonResponse
def graphql_error_response(message, status_code=400):
error = {'message': message}
return JsonResponse({'errors': [error]}, status=status_code)
from django.apps import AppConfig
class CoreConfig(AppConfig):
name = 'core'
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']
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',
]
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)
# 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'),
),
]
# 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),
),
]
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment