[odrs-web] Convert everything to sqlalchemy and add lots of nice unit tests



commit 315689424973b204f2c462bf0bb1b2922c6dec37
Author: Richard Hughes <richard hughsie com>
Date:   Wed Jun 26 13:06:15 2019 +0100

    Convert everything to sqlalchemy and add lots of nice unit tests

 Dockerfile                                    |  15 +-
 app_data/Makefile                             |   3 +-
 app_data/README.md                            |   1 -
 app_data/contrib/pylintrc                     |  65 +++
 app_data/cron.py                              |  42 +-
 app_data/migrations/script.py.mako            |  22 +
 app_data/migrations/versions/README.md        |   4 +
 app_data/migrations/versions/b8243269e9cf_.py |  28 +
 app_data/odrs/__init__.py                     |  47 +-
 app_data/odrs/db.py                           | 785 --------------------------
 app_data/odrs/dbutils.py                      |  23 +
 app_data/odrs/example.cfg                     |  16 +
 app_data/odrs/models.py                       | 215 ++++++-
 app_data/odrs/templates/default.html          | 266 +++++----
 app_data/odrs/templates/delete.html           |   2 +-
 app_data/odrs/templates/distros.html          |   1 -
 app_data/odrs/templates/graph-month.html      |   2 +-
 app_data/odrs/templates/graph-year.html       |   2 +-
 app_data/odrs/templates/index.html            |   4 +-
 app_data/odrs/templates/login.html            |  34 +-
 app_data/odrs/templates/modadmin.html         |   6 +-
 app_data/odrs/templates/mods.html             |  17 +-
 app_data/odrs/templates/privacy.html          |   8 +-
 app_data/odrs/templates/show-all.html         |  37 +-
 app_data/odrs/templates/show.html             | 298 +++++-----
 app_data/odrs/templates/stats.html            |  10 +-
 app_data/odrs/templates/users.html            |  28 +-
 app_data/odrs/tests/odrs_test.py              | 523 +++++++++++++++++
 app_data/odrs/tests/util_test.py              |  55 ++
 app_data/odrs/util.py                         | 132 ++++-
 app_data/odrs/views.py                        | 605 ++------------------
 app_data/odrs/views_admin.py                  | 534 ++++++++----------
 app_data/odrs/views_api.py                    | 447 +++++++++++++++
 app_data/pylint_test.py                       |  40 ++
 app_data/requirements.txt                     |   2 +
 app_data/schema.sql                           |  75 ---
 36 files changed, 2268 insertions(+), 2126 deletions(-)
---
diff --git a/Dockerfile b/Dockerfile
index cafe84c..1c6cd11 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,8 +3,13 @@ FROM centos/httpd-24-centos7
 USER root 
 
 RUN yum install -y gcc make \
+    mysql-devel \
+    python36-click \
     python36-cryptography \
+    python36-dateutil \
     python36-devel \
+    python36-jinja2 \
+    python36-markupsafe \
     python36-pip \
     python36-PyMySQL \
     rh-python36-mod_wsgi \
@@ -13,9 +18,13 @@ RUN yum install -y gcc make \
 ENV ODRS_HOME=/opt/app-root/src \
     LANG=C
 
-COPY app_data/requirements.txt /tmp/requirements.txt
-
-RUN pip3 install --prefix=/usr -r /tmp/requirements.txt
+RUN pip3 install --prefix=/usr \
+    flask-login \
+    flask-migrate \
+    flask-wtf \
+    mysqlclient \
+    PyMySQL \
+    sqlalchemy
 
 WORKDIR ${ODRS_HOME}
 
diff --git a/app_data/Makefile b/app_data/Makefile
index 0788180..39067b0 100644
--- a/app_data/Makefile
+++ b/app_data/Makefile
@@ -15,11 +15,12 @@ clean:
        rm -rf ./htmlcov
 
 run:
+       FLASK_DEBUG=1 \
        ODRS_REVIEWS_SECRET=1 \
+       ODRS_CONFIG=example.cfg \
        FLASK_APP=odrs/__init__.py \
        HOME=/home/hughsie/Code/odrs-web/app_data \
        $(VENV)/bin/flask run
-       #FLASK_DEBUG=1
 
 dbup:
        ODRS_CONFIG=example.cfg \
diff --git a/app_data/README.md b/app_data/README.md
index 662d1bb..916b8dc 100644
--- a/app_data/README.md
+++ b/app_data/README.md
@@ -10,7 +10,6 @@ To set up the database tables do:
     CREATE USER 'test'@'localhost' IDENTIFIED BY 'test';
     USE odrs;
     GRANT ALL ON odrs.* TO 'test'@'localhost';
-    SOURCE /path/to/schema.sql
 
 The default admin password is `Pa$$w0rd`
 
diff --git a/app_data/contrib/pylintrc b/app_data/contrib/pylintrc
new file mode 100644
index 0000000..790813e
--- /dev/null
+++ b/app_data/contrib/pylintrc
@@ -0,0 +1,65 @@
+[MASTER]
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=yes
+
+[MESSAGES CONTROL]
+
+# Disable the message, report, category or checker with the given id(s)
+disable=invalid-name,len-as-condition,missing-docstring,too-many-return-statements,too-many-branches
+
+[REPORTS]
+
+# Tells whether to display a full report or only the messages
+reports=no
+
+# Activate the evaluation score.
+score=no
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid to define new builtins when possible.
+additional-builtins=
+
+# A regular expression matching the name of dummy variables (i.e. expectedly
+# not used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+#init-import=yes
+
+[TYPECHECK]
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=query,commit,add,delete,flush,rollback,expire_all
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=LF
+
+# Maximum number of characters on a single line.
+max-line-length=120
+
+[DESIGN]
+
+# Maximum number of arguments for function / method
+max-args=10
+
+# Maximum number of statements in function / method body
+max-statements=150
+
+# Maximum number of locals for function / method body
+max-locals=30
+
+# Maximum number of lines in a module
+max-module-lines=1100
diff --git a/app_data/cron.py b/app_data/cron.py
index 12b57fe..1eed918 100755
--- a/app_data/cron.py
+++ b/app_data/cron.py
@@ -1,14 +1,33 @@
 #!/usr/bin/python3
 # -*- coding: utf-8 -*-
 #
-# Copyright (C) 2015-2018 Richard Hughes <richard hughsie com>
+# Copyright (C) 2015-2019 Richard Hughes <richard hughsie com>
 #
 # SPDX-License-Identifier: GPL-3.0+
 
 import json
 import sys
 
-from odrs.db import Database, CursorError
+from odrs import db
+
+from odrs.models import Review
+from odrs.util import _get_rating_for_app_id
+
+def _regenerate_ratings(fn):
+    item = {}
+
+    app_ids = [res[0] for res in db.session.query(Review.app_id).\
+                       order_by(Review.app_id.asc()).\
+                       distinct(Review.app_id).all()]
+    for app_id in app_ids:
+        ratings = _get_rating_for_app_id(app_id, 2)
+        if len(ratings) == 0:
+            continue
+        item[app_id] = ratings
+
+    # dump to file
+    with open(fn, 'w') as outfd:
+        outfd.write(json.dumps(item, sort_keys=True, indent=4, separators=(',', ': ')))
 
 if __name__ == '__main__':
 
@@ -18,24 +37,7 @@ if __name__ == '__main__':
 
     # create the ratings data
     if sys.argv[1] == 'ratings':
-        item = {}
-        try:
-            db = Database(None)
-            app_ids = db.reviews.get_all_apps()
-            for app_id in app_ids:
-                ratings = db.reviews.get_rating_for_app_id(app_id, 2)
-                if len(ratings) == 0:
-                    continue
-                item[app_id] = ratings
-        except CursorError as e:
-            print(str(e))
-            sys.exit(1)
-
-        # dump to file
-        dat = json.dumps(item, sort_keys=True, indent=4, separators=(',', ': '))
-        outfd = open(sys.argv[2], "w")
-        outfd.write(dat)
-        outfd.close()
+        _regenerate_ratings(sys.argv[2])
     else:
         print("cron mode %s not known" % sys.argv[1])
         sys.exit(1)
diff --git a/app_data/migrations/script.py.mako b/app_data/migrations/script.py.mako
new file mode 100644
index 0000000..89dc3eb
--- /dev/null
+++ b/app_data/migrations/script.py.mako
@@ -0,0 +1,22 @@
+"""
+
+Revision ID: ${up_revision}
+Revises: ${down_revision}
+Create Date: ${create_date}
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+def upgrade():
+    ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+    ${downgrades if downgrades else "pass"}
diff --git a/app_data/migrations/versions/README.md b/app_data/migrations/versions/README.md
new file mode 100644
index 0000000..ad92cdb
--- /dev/null
+++ b/app_data/migrations/versions/README.md
@@ -0,0 +1,4 @@
+Version Upgrades
+================
+
+Do not rely on versions being upgradable without manual work!
diff --git a/app_data/migrations/versions/b8243269e9cf_.py b/app_data/migrations/versions/b8243269e9cf_.py
new file mode 100644
index 0000000..4503876
--- /dev/null
+++ b/app_data/migrations/versions/b8243269e9cf_.py
@@ -0,0 +1,28 @@
+"""
+
+Revision ID: b8243269e9cf
+Revises: None
+Create Date: 2019-06-28 12:39:37.287224
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'b8243269e9cf'
+down_revision = None
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+def upgrade():
+    op.alter_column('reviews', 'date_deleted',
+               existing_type=mysql.TIMESTAMP(),
+               nullable=True,
+               existing_server_default=sa.text("'0000-00-00 00:00:00'"))
+
+
+def downgrade():
+    op.alter_column('reviews', 'date_deleted',
+               existing_type=mysql.TIMESTAMP(),
+               nullable=False,
+               existing_server_default=sa.text("'0000-00-00 00:00:00'"))
diff --git a/app_data/odrs/__init__.py b/app_data/odrs/__init__.py
index 3a372af..82a06a1 100644
--- a/app_data/odrs/__init__.py
+++ b/app_data/odrs/__init__.py
@@ -3,7 +3,7 @@
 #
 # pylint: disable=invalid-name,missing-docstring,wrong-import-order,wrong-import-position
 #
-# Copyright (C) 2015-2017 Richard Hughes <richard hughsie com>
+# Copyright (C) 2015-2019 Richard Hughes <richard hughsie com>
 #
 # SPDX-License-Identifier: GPL-3.0+
 
@@ -11,33 +11,47 @@ import os
 
 from flask import Flask, flash, render_template, g
 from flask_login import LoginManager
+from flask_migrate import Migrate
+from flask_sqlalchemy import SQLAlchemy
+from werkzeug.local import LocalProxy
 
-from .db import Database
+from .dbutils import drop_db, init_db
 
 app = Flask(__name__)
 app.config.from_object(__name__)
-app.secret_key = os.environ['ODRS_REVIEWS_SECRET']
+if 'ODRS_CONFIG' in os.environ:
+    app.config.from_envvar('ODRS_CONFIG')
+if 'ODRS_REVIEWS_SECRET' in os.environ:
+    app.secret_key = os.environ['ODRS_REVIEWS_SECRET']
+for key in ['SQLALCHEMY_DATABASE_URI',
+            'SQLALCHEMY_TRACK_MODIFICATIONS']:
+    if key in os.environ:
+        app.config[key] = os.environ[key]
+
+db = SQLAlchemy(app)
+
+migrate = Migrate(app, db)
+
+@app.cli.command('initdb')
+def initdb_command():
+    init_db(db)
+
+@app.cli.command('dropdb')
+def dropdb_command():
+    drop_db(db)
 
 lm = LoginManager()
 lm.init_app(app)
 
-def get_db():
-    db = getattr(g, '_database', None)
-    if db is None:
-        db = g.db = Database(app)
-    return db
-
 @app.teardown_appcontext
-def teardown_request(exception):
-    db = getattr(g, 'db', None)
-    if db is not None:
-        db.close()
+def shutdown_session(unused_exception=None):
+    db.session.remove()
 
 @lm.user_loader
 def load_user(user_id):
-    db = get_db()
-    user = db.moderators.get_by_id(user_id)
-    return user
+    from .models import Moderator
+    g.user = db.session.query(Moderator).filter(Moderator.moderator_id == user_id).first()
+    return g.user
 
 @app.errorhandler(404)
 def error_page_not_found(msg=None):
@@ -46,4 +60,5 @@ def error_page_not_found(msg=None):
     return render_template('error.html'), 404
 
 from odrs import views
+from odrs import views_api
 from odrs import views_admin
diff --git a/app_data/odrs/dbutils.py b/app_data/odrs/dbutils.py
new file mode 100644
index 0000000..b6555ac
--- /dev/null
+++ b/app_data/odrs/dbutils.py
@@ -0,0 +1,23 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2015-2019 Richard Hughes <richard hughsie com>
+#
+# SPDX-License-Identifier: GPL-3.0+
+
+def init_db(db):
+
+    # ensure all tables exist
+    db.metadata.create_all(bind=db.engine)
+
+    # ensure admin user exists
+    from .models import Moderator
+    if not db.session.query(Moderator).filter(Moderator.username == 'admin').first():
+        u = Moderator(username='admin')
+        u.password = 'Pa$$w0rd'
+        u.is_admin = True
+        db.session.add(u)
+        db.session.commit()
+
+def drop_db(db):
+    db.metadata.drop_all(bind=db.engine)
diff --git a/app_data/odrs/example.cfg b/app_data/odrs/example.cfg
new file mode 100644
index 0000000..f0e744f
--- /dev/null
+++ b/app_data/odrs/example.cfg
@@ -0,0 +1,16 @@
+import os
+DEBUG = True
+PROPAGATE_EXCEPTIONS = True
+SECRET_KEY = 'not-secret4'
+HOST_NAME = 'localhost'
+APP_NAME = 'ODRS'
+IP = '127.0.0.1'
+PORT = 5000
+SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://test:test@localhost/odrs?charset=utf8mb4'
+SQLALCHEMY_TRACK_MODIFICATIONS = False
+MYSQL_DATABASE_CHARSET = 'utf8mb4'
+ODRS_REVIEWS_SECRET = '1'
+
+# this is only for testing, to avoid needing SSL when using http://localhost/
+SESSION_COOKIE_SECURE = False
+REMEMBER_COOKIE_SECURE = False
diff --git a/app_data/odrs/models.py b/app_data/odrs/models.py
index 67a36c0..00476ae 100644
--- a/app_data/odrs/models.py
+++ b/app_data/odrs/models.py
@@ -3,22 +3,112 @@
 #
 # pylint: disable=invalid-name,missing-docstring,too-few-public-methods,too-many-instance-attributes
 #
-# Copyright (C) 2015-2017 Richard Hughes <richard hughsie com>
+# Copyright (C) 2015-2019 Richard Hughes <richard hughsie com>
 #
 # SPDX-License-Identifier: GPL-3.0+
 
-class User():
+import datetime
+
+from werkzeug.security import generate_password_hash, check_password_hash
+
+from sqlalchemy import Column, Integer, String, Text, DateTime, Index
+
+from odrs import db
+
+from .util import _password_hash, _get_user_key
+
+def _vote_exists(review_id, user_hash):
+    """ Checks to see if a vote exists for the review+user """
+    return db.session.query(Vote).\
+                filter(Vote.review_id == review_id).\
+                filter(Vote.user_hash == user_hash).\
+                first()
+
+class Analytic(db.Model):
+
+    # sqlalchemy metadata
+    __tablename__ = 'analytics'
+    __table_args__ = (Index('datestr', 'datestr', 'app_id', unique=True),
+                      {'mysql_character_set': 'utf8mb4'}
+                     )
+
+    datestr = Column(Integer, default=0, primary_key=True)
+    app_id = Column(String(128), primary_key=True)
+    fetch_cnt = Column(Integer, default=1)
+
     def __init__(self):
-        self.id = None
+        self.datestr = None
+
+    def __repr__(self):
+        return 'Analytic object %s' % self.analytic_id
+
+class Vote(db.Model):
+
+    # sqlalchemy metadata
+    __tablename__ = 'votes'
+    __table_args__ = {'mysql_character_set': 'utf8mb4'}
+
+    vote_id = Column(Integer, primary_key=True, nullable=False, unique=True)
+    date_created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
+    user_hash = Column(Text)
+    val = Column(Integer, default=0)
+    review_id = Column(Integer, default=0)
+
+    def __init__(self, user_hash, val, review_id=0):
+        self.review_id = review_id
+        self.user_hash = user_hash
+        self.val = val
+
+    def __repr__(self):
+        return 'Vote object %s' % self.vote_id
+
+class User(db.Model):
+
+    # sqlalchemy metadata
+    __tablename__ = 'users'
+    __table_args__ = {'mysql_character_set': 'utf8mb4'}
+    __table_args__ = (Index('users_hash_idx', 'user_hash'),
+                      {'mysql_character_set': 'utf8mb4'}
+                     )
+
+    user_id = Column(Integer, primary_key=True, nullable=False, unique=True)
+    date_created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
+    user_hash = Column(Text)
+    karma = Column(Integer, default=0)
+    is_banned = Column(Integer, default=0)
+
+    def __init__(self, user_hash=None):
+        self.user_hash = user_hash
         self.karma = 0
-        self.date_created = 0
-        self.user_hash = 0
         self.is_banned = 0
 
-class Review():
+    def __repr__(self):
+        return 'User object %s' % self.user_id
+
+class Review(db.Model):
+
+    # sqlalchemy metadata
+    __tablename__ = 'reviews'
+    __table_args__ = {'mysql_character_set': 'utf8mb4'}
+
+    review_id = Column(Integer, primary_key=True, nullable=False, unique=True)
+    date_created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
+    date_deleted = Column(DateTime)
+    app_id = Column(Text)
+    locale = Column(Text)
+    summary = Column(Text)
+    description = Column(Text)
+    user_hash = Column(Text)
+    user_addr = Column(Text)
+    user_display = Column(Text)
+    version = Column(Text)
+    distro = Column(Text)
+    rating = Column(Integer, default=0)
+    karma_up = Column(Integer, default=0)
+    karma_down = Column(Integer, default=0)
+    reported = Column(Integer, default=0)
+
     def __init__(self):
-        self.review_id = 0
-        self.date_created = 0
         self.app_id = None
         self.locale = None
         self.summary = None
@@ -30,30 +120,101 @@ class Review():
         self.user_hash = None
         self.user_display = None
         self.rating = 0
-        self.date_deleted = None
-        self.reported = None
+        self.reported = 0
 
-class Event():
-    def __init__(self):
-        self.eventlog_id = 0
-        self.date_created = 0
-        self.user_addr = None
-        self.user_hash = None
-        self.message = None
-        self.app_id = None
-        self.important = False
+    def asdict(self, user_hash=None):
+        item = {
+            'app_id': self.app_id,
+            'date_created': self.date_created.timestamp(),
+            'description': self.description,
+            'distro': self.distro,
+            'karma_down': self.karma_down,
+            'karma_up': self.karma_up,
+            'locale': self.locale,
+            'rating': self.rating,
+            'reported': self.reported,
+            'review_id': self.review_id,
+            'summary': self.summary,
+            'user_display': self.user_display,
+            'user_hash': self.user_hash,
+            'version': self.version,
+        }
+        if user_hash:
+            item['user_skey'] = _get_user_key(user_hash, self.app_id)
+        return item
 
-class Moderator():
-    def __init__(self):
-        self.moderator_id = 0
-        self.username = None
-        self.password = None
-        self.display_name = None
-        self.email = None
+    def __repr__(self):
+        return 'Review object %s' % self.review_id
+
+class Event(db.Model):
+
+    # sqlalchemy metadata
+    __tablename__ = 'eventlog'
+    __table_args__ = (Index('message_idx', 'message', mysql_length=8),
+                      Index('date_created_idx', 'date_created'),
+                      {'mysql_character_set': 'utf8mb4'}
+                     )
+
+    eventlog_id = Column(Integer, primary_key=True, nullable=False, unique=True)
+    date_created = Column(DateTime, nullable=False, default=datetime.datetime.utcnow)
+    user_addr = Column(Text)
+    user_hash = Column(Text)
+    message = Column(Text)
+    app_id = Column(Text)
+    important = Column(Integer, default=0)
+
+    def __init__(self, user_addr, user_hash=None, app_id=None, message=None, important=False):
+        self.user_addr = user_addr
+        self.user_hash = user_hash
+        self.message = message
+        self.app_id = app_id
+        self.important = important
+
+    def __repr__(self):
+        return 'Event object %s' % self.eventlog_id
+
+class Moderator(db.Model):
+
+    # sqlalchemy metadata
+    __tablename__ = 'moderators'
+    __table_args__ = {'mysql_character_set': 'utf8mb4'}
+
+    moderator_id = Column(Integer, primary_key=True, nullable=False, unique=True)
+    username = Column(Text)
+    password_hash = Column('password', Text)
+    display_name = Column(Text)
+    email = Column(Text)
+    is_enabled = Column(Integer, default=0)
+    is_admin = Column(Integer, default=0)
+    user_hash = Column(Text)
+    locales = Column(Text)
+
+    def __init__(self, username=None, password=None, display_name=None, email=None):
+        self.username = username
+        self.display_name = display_name
+        self.email = email
         self.is_enabled = False
         self.is_admin = False
         self.user_hash = None
         self.locales = None
+        self.locales = password
+
+    @property
+    def password(self):
+        raise AttributeError('password is not a readable attribute')
+
+    @password.setter
+    def password(self, password):
+        self.password_hash = generate_password_hash(password)
+
+    def verify_password(self, password):
+        # on success, upgrade the old hashing function to the new secure one
+        if len(self.password_hash) == 40:
+            if self.password_hash != _password_hash(password):
+                return False
+            self.password = password
+            return True
+        return check_password_hash(self.password_hash, password)
 
     @property
     def is_authenticated(self):
@@ -71,4 +232,4 @@ class Moderator():
         return str(self.moderator_id)
 
     def __repr__(self):
-        return '<Moderator %r>' % (self.moderator_id)
+        return 'Moderator object %s' % self.moderator_id
diff --git a/app_data/odrs/templates/default.html b/app_data/odrs/templates/default.html
index 274e54e..a8b5f24 100644
--- a/app_data/odrs/templates/default.html
+++ b/app_data/odrs/templates/default.html
@@ -1,7 +1,7 @@
 <!DOCTYPE html>
 
 <!-- Copyright (C) 2016-2019 Richard Hughes <richard hughsie com>
-     SPDX-License-Identifier: GPL-3.0+ -->
+   SPDX-License-Identifier: GPL-3.0+ -->
 <html lang="en">
 <head>
   <title>{% block title %}{% endblock %}</title>
@@ -15,145 +15,143 @@
 </head>
 
 <body class="gnome-body">
-    <header class="gnome-header">
-        <nav class="navbar navbar-fixed-top navbar-default affix-top">
-            <div class="container">
-                <div class="navbar-header">
-                    <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" 
data-target="#navbar-wrapper" aria-expanded="false">
-                        <span class="sr-only">Toggle navigation</span>
-                        <span class="icon-bar top-bar"></span>
-                        <span class="icon-bar middle-bar"></span>
-                        <span class="icon-bar bottom-bar"></span>
-                    </button>
-                    <a href="/" class="gnome-navbar-brand" title="Go to home page">
-                        <img src="src/images/gnome-logo.svg" alt="GNOME: Open Desktop Review Server">
-                    </a>
-                </div>
-                <div class="navbar-collapse collapse" id="navbar-wrapper">
-                    <ul class="nav navbar-nav">
-                            <li><a href="/admin/show/all">All Reviews</a></li>
-                        {% if current_user.is_authenticated %}
-                            <li><a href="/admin/show/unmoderated">Queue</a></li>
-                            <li><a href="/admin/show/reported">Reported</a></li>
-                        {% endif %}
-                        {% if current_user.is_admin %}
-                            <li><a href="/admin/stats">Statistics</a></li>
-                            <li><a href="/admin/users/all">Users</a></li>
-                            <li><a href="/admin/moderators/all">Moderators</a></li>
-                            <li><a href="/admin/distros">Distributions</a></li>
-                            <li><a href="/admin/graph_month">Usage</a></li>
-                        {% endif %}
-                        {% if not current_user.is_admin and current_user.is_authenticated %}
-                            <li><a href="/admin/moderator/{{current_user.username}}/admin">Profile</a></li>
-                        {% endif %}
-                    </ul>
-                    <ul class="nav navbar-nav navbar-right">
-                        {% if current_user.is_authenticated %}
-                            <li><a href="/logout">Logout</a></li>
-                        {% else %}
-                            <li><a href="/login">Login</a></li>
-                        {% endif %}
-                    </ul>
-                </div>
-            </div>
-        </nav>
-    </header> <!-- gnome-header -->
+  <header class="gnome-header">
+    <nav class="navbar navbar-fixed-top navbar-default affix-top">
+      <div class="container">
+        <div class="navbar-header">
+          <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" 
data-target="#navbar-wrapper" aria-expanded="false">
+            <span class="sr-only">Toggle navigation</span>
+            <span class="icon-bar top-bar"></span>
+            <span class="icon-bar middle-bar"></span>
+            <span class="icon-bar bottom-bar"></span>
+          </button>
+          <a href="/" class="gnome-navbar-brand" title="Go to home page">
+            <img src="src/images/gnome-logo.svg" alt="GNOME: Open Desktop Review Server">
+          </a>
+        </div>
+        <div class="navbar-collapse collapse" id="navbar-wrapper">
+          <ul class="nav navbar-nav">
+            <li><a href="{{url_for('.admin_show_all')}}">All Reviews</a></li>
+{% if g.user is defined %}
+            <li><a href="{{url_for('.odrs_show_unmoderated')}}">Queue</a></li>
+            <li><a href="{{url_for('.odrs_show_reported')}}">Reported</a></li>
+{% if g.user.is_admin %}
+            <li><a href="{{url_for('.admin_show_stats')}}">Statistics</a></li>
+            <li><a href="{{url_for('.admin_users_all')}}">Users</a></li>
+            <li><a href="{{url_for('.admin_moderator_show_all')}}">Moderators</a></li>
+            <li><a href="{{url_for('.admin_distros')}}">Distributions</a></li>
+            <li><a href="{{url_for('.admin_graph_month')}}">Usage</a></li>
+{% endif %}
+            <li><a href="{{url_for('.odrs_moderator_show', 
moderator_id=g.user.moderator_id)}}">Profile</a></li>
+{% endif %}
+          </ul>
+          <ul class="nav navbar-nav navbar-right">
+{% if g.user is defined %}
+              <li><a href="{{url_for('.odrs_logout')}}">Logout</a></li>
+{% else %}
+              <li><a href="{{url_for('.odrs_login')}}">Login</a></li>
+{% endif %}
+          </ul>
+        </div>
+      </div>
+    </nav>
+  </header> <!-- gnome-header -->
 
-    <div class="gnome-content">
-        <div class="container">
-            <div class="row">
-                 <div class="col-xs-12">
-                    {% block navigation %}
-                    {% endblock %}
+  <div class="gnome-content">
+    <div class="container">
+      <div class="row">
+         <div class="col-xs-12">
+{% block navigation %}
+{% endblock %}
 
-                    <!-- any flashed messages -->
-                    <div id="messages">
-                      {% for category, msg in get_flashed_messages(with_categories=true) %}
-                        <p class="alert alert-info message flash-{{ category }}">{{ msg }}</p>
-                      {% endfor %}
-                    </div>
+          <!-- any flashed messages -->
+          <div id="messages">
+{% for category, msg in get_flashed_messages(with_categories=true) %}
+            <p class="alert alert-info message flash-{{category}}">{{msg}}</p>
+{% endfor %}
+          </div>
 
-                    {% block content %}{% endblock %}
-                  </div>
-             </div>
-        </div>
-    </div><!-- end of gnome-content -->
+{% block content %}{% endblock %}
+          </div>
+       </div>
+    </div>
+  </div><!-- end of gnome-content -->
 
-    <div class="footer">
-        <div class="container">
-            <div class="row">
-                <div class="col-xs-12 col-sm-2">
-                    <h4><a href="https://www.gnome.org";>The GNOME Project</a></h4>
-                    <ul>
-                        <li>
-                            <a href="https://www.gnome.org/about/";>About Us</a>
-                        </li>
-                        <li>
-                            <a href="https://www.gnome.org/get-involved/";>Get Involved</a>
-                        </li>
-                        <li>
-                            <a href="https://www.gnome.org/teams/";>Teams</a>
-                        </li>
-                        <li>
-                            <a href="https://www.gnome.org/support-gnome/";>Support GNOME</a>
-                        </li>
-                        <li>
-                            <a href="https://www.gnome.org/contact/";>Contact Us</a>
-                        </li>
-                        <li>
-                            <a href="https://www.gnome.org/foundation/";>The GNOME Foundation</a>
-                        </li>
-                    </ul>
-                </div>
-                <div class="col-xs-12 col-sm-2">
-                    <h4><a href="#">Resources</a></h4>
-                    <ul class="sub-menu">
-                        <li>
-                            <a href="https://developer.gnome.org";>Developer Center</a>
-                        </li>
-                        <li>
-                            <a href="https://help.gnome.org";>Documentation</a>
-                        </li>
-                        <li>
-                            <a href="https://wiki.gnome.org";>Wiki</a>
-                        </li>
-                        <li>
-                            <a href="https://mail.gnome.org/mailman/listinfo";>Mailing Lists</a>
-                        </li>
-                        <li>
-                            <a href="https://wiki.gnome.org/GettingInTouch/IRC";>IRC Channels</a>
-                        </li>
-                        <li>
-                            <a href="https://gitlab.gnome.org/";>Bug Tracker</a>
-                        </li>
-                        <li>
-                            <a href="https://gitlab.gnome.org/";>Development Code</a>
-                        </li>
-                    </ul>
-                </div>
-                <div class="col-xs-12 col-sm-2">
-                    <h4><a href="https://www.gnome.org/news/";>News</a></h4>
-                    <ul class="sub-menu">
-                        <li>
-                            <a href="https://www.gnome.org/press/";>Press Releases</a>
-                        </li>
-                        <li>
-                            <a href="https://www.gnome.org/start/stable";>Latest Release</a>
-                        </li>
-                        <li>
-                            <a href="https://planet.gnome.org";>Planet GNOME</a>
-                        </li>
-                    </ul>
-                </div>
-            </div>
-            <div class="row">
-                <div class="col-xs-12">
-                    <p>© <a href="https://www.gnome.org/";>The GNOME Project</a>.</p>
-                    <p>GNOME: Open Desktop Review Server. Hosted by <a href="https://www.redhat.com/";>Red 
Hat</a>.
-                </div>
-            </div>
+  <div class="footer">
+    <div class="container">
+      <div class="row">
+        <div class="col-xs-12 col-sm-2">
+          <h4><a href="https://www.gnome.org";>The GNOME Project</a></h4>
+          <ul>
+            <li>
+              <a href="https://www.gnome.org/about/";>About Us</a>
+            </li>
+            <li>
+              <a href="https://www.gnome.org/get-involved/";>Get Involved</a>
+            </li>
+            <li>
+              <a href="https://www.gnome.org/teams/";>Teams</a>
+            </li>
+            <li>
+              <a href="https://www.gnome.org/support-gnome/";>Support GNOME</a>
+            </li>
+            <li>
+              <a href="https://www.gnome.org/contact/";>Contact Us</a>
+            </li>
+            <li>
+              <a href="https://www.gnome.org/foundation/";>The GNOME Foundation</a>
+            </li>
+          </ul>
+        </div>
+        <div class="col-xs-12 col-sm-2">
+          <h4><a href="#">Resources</a></h4>
+          <ul class="sub-menu">
+            <li>
+              <a href="https://developer.gnome.org";>Developer Center</a>
+            </li>
+            <li>
+              <a href="https://help.gnome.org";>Documentation</a>
+            </li>
+            <li>
+              <a href="https://wiki.gnome.org";>Wiki</a>
+            </li>
+            <li>
+              <a href="https://mail.gnome.org/mailman/listinfo";>Mailing Lists</a>
+            </li>
+            <li>
+              <a href="https://wiki.gnome.org/GettingInTouch/IRC";>IRC Channels</a>
+            </li>
+            <li>
+              <a href="https://gitlab.gnome.org/";>Bug Tracker</a>
+            </li>
+            <li>
+              <a href="https://gitlab.gnome.org/";>Development Code</a>
+            </li>
+          </ul>
+        </div>
+        <div class="col-xs-12 col-sm-2">
+          <h4><a href="https://www.gnome.org/news/";>News</a></h4>
+          <ul class="sub-menu">
+            <li>
+              <a href="https://www.gnome.org/press/";>Press Releases</a>
+            </li>
+            <li>
+              <a href="https://www.gnome.org/start/stable";>Latest Release</a>
+            </li>
+            <li>
+              <a href="https://planet.gnome.org";>Planet GNOME</a>
+            </li>
+          </ul>
+        </div>
+      </div>
+      <div class="row">
+        <div class="col-xs-12">
+          <p>© <a href="https://www.gnome.org/";>The GNOME Project</a>.</p>
+          <p>GNOME: Open Desktop Review Server. Hosted by <a href="https://www.redhat.com/";>Red Hat</a>.
         </div>
+      </div>
     </div>
+  </div>
 
   <script src="https://static.gnome.org/js/jquery-2.1.4.min.js";></script>
   <script src="https://static.gnome.org/js/deneb.min.js";></script>
diff --git a/app_data/odrs/templates/delete.html b/app_data/odrs/templates/delete.html
index 84661f8..9d8a26c 100644
--- a/app_data/odrs/templates/delete.html
+++ b/app_data/odrs/templates/delete.html
@@ -7,7 +7,7 @@
 Once deleted, reviews are gone <b>forever</b>.
 </p>
 
-<a href="/admin/delete/{{ review_id }}/force">
+<a href="{{url_for('.admin_delete_force', review_id=review_id)}}">
 <button class="btn btn-danger" type="submit">Irrevocably Remove Review</button>
 </a>
 
diff --git a/app_data/odrs/templates/distros.html b/app_data/odrs/templates/distros.html
index 7d23197..a2dba73 100644
--- a/app_data/odrs/templates/distros.html
+++ b/app_data/odrs/templates/distros.html
@@ -29,5 +29,4 @@ var data = {
 var myBarChartDistros = new Chart(ctx).Bar(data, null);
 </script>
 
-
 {% endblock %}
diff --git a/app_data/odrs/templates/graph-month.html b/app_data/odrs/templates/graph-month.html
index d69c57a..d80eb73 100644
--- a/app_data/odrs/templates/graph-month.html
+++ b/app_data/odrs/templates/graph-month.html
@@ -7,7 +7,7 @@
 Chart.defaults.global.animation = false;
 </script>
 
-<a href="/admin/graph_year">Show the graphs for the last year...</a>
+<a href="{{url_for('.admin_graph_year')}}">Show the graphs for the last year...</a>
 
 <h2>Review Requests</h2>
 <canvas id="reviewsChart" width="900" height="400"></canvas>
diff --git a/app_data/odrs/templates/graph-year.html b/app_data/odrs/templates/graph-year.html
index ec52c86..a9f8935 100644
--- a/app_data/odrs/templates/graph-year.html
+++ b/app_data/odrs/templates/graph-year.html
@@ -7,7 +7,7 @@
 Chart.defaults.global.animation = false;
 </script>
 
-<a href="/admin/graph_year">Show the graphs for the last month...</a>
+<a href="{{url_for('.admin_graph_month')}}">Show the graphs for the last month...</a>
 
 <h2>Review Requests</h2>
 <canvas id="reviewsChart" width="900" height="400"></canvas>
diff --git a/app_data/odrs/templates/index.html b/app_data/odrs/templates/index.html
index e04c002..a9072b4 100644
--- a/app_data/odrs/templates/index.html
+++ b/app_data/odrs/templates/index.html
@@ -66,7 +66,7 @@ To then vote or remove a specific review you need to specify the
 <code>review_id</code>.
 </p>
 <p>
-  Find the GDPR policy <a href="/privacy">here</a>.
+  Find the GDPR policy <a href="/odrs_privacy">here</a>.
 </p>
 
 <h2>Moderation</h2>
@@ -74,7 +74,7 @@ To then vote or remove a specific review you need to specify the
 For projects like RHEL where we care very much what comments are shown
 to paying customers we definitely want reviews to be pre-approved and
 checked before showing to customers.
-For distros like Fedora we don't have this luxury and so we're going to
+For admin_distros like Fedora we don't have this luxury and so we're going to
 rely on the community to self-regulate reviews.
 Reviews are either up-voted or down-voted according how useful they are
 along with the nuclear option of marking the review as abusive.
diff --git a/app_data/odrs/templates/login.html b/app_data/odrs/templates/login.html
index 27bfb1d..41af145 100644
--- a/app_data/odrs/templates/login.html
+++ b/app_data/odrs/templates/login.html
@@ -4,23 +4,23 @@
 {% block content %}
 
 <div class="col-sm-8 col-md-6 col-centered">
-    <form class="form" method="POST" action="">
-        <h1>User Login</h1>
-        <div class="form-group">
-        <label for="username" class="control-label">Username:</label>
-          <input type="text" class="form-control" id="username" name="username" required>
-      </div>
-      <div class="form-group">
-        <label for="password" class="control-label">Password:</label>
-          <input type="password" class="form-control" id="password" name="password" required>
-      </div>
-      <div class="form-group">
-        <button class="btn btn-action btn-block" type="submit">Log in</button>
-      </div>
-      <hr>
-      <p class="text-center main_feature" style="margin-bottom: 30px;">Don't have an account?</p>
-      <p class="text-center">You can request an account if you think it is required.</p>
-    </form>
+  <form class="form" method="POST" action="">
+    <h1>User Login</h1>
+    <div class="form-group">
+    <label for="username" class="control-label">Username:</label>
+      <input type="text" class="form-control" id="username" name="username" required>
+    </div>
+    <div class="form-group">
+    <label for="password" class="control-label">Password:</label>
+      <input type="password" class="form-control" id="password" name="password" required>
+    </div>
+    <div class="form-group">
+    <button class="btn btn-action btn-block" type="submit">Log in</button>
+    </div>
+    <hr>
+    <p class="text-center main_feature" style="margin-bottom: 30px;">Don't have an account?</p>
+    <p class="text-center">You can request an account if you think it is required.</p>
+  </form>
 </div>
 
 {% endblock %}
diff --git a/app_data/odrs/templates/modadmin.html b/app_data/odrs/templates/modadmin.html
index 3d09bad..7fb8238 100644
--- a/app_data/odrs/templates/modadmin.html
+++ b/app_data/odrs/templates/modadmin.html
@@ -5,7 +5,7 @@
 
 <h1>Details of user ‘{{u.username}}’</h1>
 
-<form method="post" action="/admin/moderator/{{u.username}}/modify_by_admin">
+<form method="post" action="{{url_for('admin_user_modify_by_admin', moderator_id=u.moderator_id)}}">
   <table>
     <tr>
       <td>Display Name:</td>
@@ -38,13 +38,13 @@
 {% else %}
         <input class="checkbox" type="checkbox" name="is_enabled" value="1"/>Account enabled</input>
 {% endif %}
-      </td>
+     </td>
     </tr>
 {% endif %}
   </table>
   <button type="submit" class="btn btn-primary btn-large" class="submit">Modify</button>
 {% if u.username != 'admin' and current_user.is_admin %}
-  <form method="get" action="/admin/moderator/{{u.username}}/delete">
+  <form method="get" action="{{url_for('admin_moderate_delete', moderator_id=u.moderator_id)}}">
 {% endif %}
     <button class="btn btn-danger btn-large">Delete</button>
   </form>
diff --git a/app_data/odrs/templates/mods.html b/app_data/odrs/templates/mods.html
index ac998c7..24ba91c 100644
--- a/app_data/odrs/templates/mods.html
+++ b/app_data/odrs/templates/mods.html
@@ -16,19 +16,19 @@
   </tr>
 {% for u in mods %}
   <tr>
-    <td> {{ u.moderator_id }} </td>
-    <td><a href="/admin/moderator/{{u.username}}/admin">{{ u.username }}</a></td>
-    <td> {{ u.display_name }} </td>
-    <td> {{ u.email }} </td>
-    <td> {{ u.is_enabled }} </td>
-    <td> {{ u.is_admin }} </td>
-    <td> {{ u.user_hash }} </td>
+    <td>{{u.moderator_id}}</td>
+    <td><a href="{{url_for('.odrs_moderator_show', moderator_id=u.moderator_id)}}">{{u.username}}</a></td>
+    <td>{{u.display_name}}</td>
+    <td>{{u.email}}</td>
+    <td>{{u.is_enabled}}</td>
+    <td>{{u.is_admin}}</td>
+    <td>{{u.user_hash}}</td>
   </tr>
 {% endfor %}
 </table>
 
 <h3>Create new</h3>
-<form method="post" action="/admin/moderator/add" class="form">
+<form method="post" action="{{url_for('admin_moderator_add')}}" class="form">
 <table>
   <tr>
     <th>Username:</th>
@@ -50,5 +50,4 @@
   <button class="btn btn-action btn-large" type="submit">Add</button>
 </form>
 
-
 {% endblock %}
diff --git a/app_data/odrs/templates/privacy.html b/app_data/odrs/templates/privacy.html
index 8c6afdc..0ead1af 100644
--- a/app_data/odrs/templates/privacy.html
+++ b/app_data/odrs/templates/privacy.html
@@ -289,7 +289,7 @@
   Upon request, a data subject should have the right to receive a copy of their
   data in a structured format, typically an SQL export.
   These requests should be processed within one month, provided there is no
-  undue burden and it does not compromise the privacy of other individuals.
+  undue burden and it does not compromise the odrs_privacy of other individuals.
   A data subject may also request that their data is transferred directly to
   another system. This is available for free.
 </p>
@@ -305,12 +305,12 @@
 
 <h2>Privacy by design and default</h2>
 <p>
-  Privacy by design is an approach to projects that promote privacy and data
+  Privacy by design is an approach to projects that promote odrs_privacy and data
   protection compliance from the start.
   The DPO will be responsible for conducting Privacy Impact Assessments and
-  ensuring that all changes commence with a privacy plan.
+  ensuring that all changes commence with a odrs_privacy plan.
   When relevant, and when it does not have a negative impact on the data subject,
-  privacy settings will be set to the most private by default.
+  odrs_privacy settings will be set to the most private by default.
 </p>
 
 <h2>Reporting breaches</h2>
diff --git a/app_data/odrs/templates/show-all.html b/app_data/odrs/templates/show-all.html
index 3ffb7b0..344fd46 100644
--- a/app_data/odrs/templates/show-all.html
+++ b/app_data/odrs/templates/show-all.html
@@ -25,31 +25,31 @@
 
 {% for r in reviews %}
   <tr>
-    <td><a href="/admin/review/{{ r.review_id }}">{{ r.review_id }}</a></td>
+    <td><a href="{{url_for('.admin_show_review', review_id=r.review_id)}}">{{r.review_id}}</a></td>
     <td>
-      {{ format_timestamp(r.date_created) }}
+      {{r.date_created}}
 {% if r.date_deleted %}
       <br/>
-      <i class="important">{{ format_timestamp(r.date_deleted) }}</i>
+      <i class="important">{{r.date_deleted}}</i>
 {% endif %}
     </td>
-    <td>{{ r.app_id.replace('.desktop', '').replace('.Application', '') }}</td>
-    <td>{{ r.version }}</td>
-    <td>{{ format_rating(r.rating) }}</td>
-    <td>{{ r.karma_up }}</td>
+    <td>{{r.app_id.replace('.desktop', '').replace('.Application', '')}}</td>
+    <td>{{r.version}}</td>
+    <td>{{format_rating(r.rating)}}</td>
+    <td>{{r.karma_up}}</td>
 {% if r.reported > 0 %}
-    <td><i class="important">{{ r.karma_down }}</i></td>
+    <td><i class="important">{{r.karma_down}}</i></td>
 {% else %}
-    <td>{{ r.karma_down }}</td>
+    <td>{{r.karma_down}}</td>
 {% endif %}
-    <td>{{ r.distro.replace(' GNU/Linux', '') }}</td>
-    <td>{{ r.locale }}</td>
+    <td>{{r.distro.replace(' GNU/Linux', '')}}</td>
+    <td>{{r.locale}}</td>
 {% if r.user_display %}
-    <td>{{ format_truncate(r.user_display, 15) }}</td>
+    <td>{{format_truncate(r.user_display, 15)}}</td>
 {% else %}
     <td><i>Unknown</i></td>
 {% endif %}
-    <td>{{ format_truncate(r.summary, 15) }}</td>
+    <td>{{format_truncate(r.summary, 15)}}</td>
   </tr>
 {% endfor %}
 
@@ -58,7 +58,7 @@
 {% macro render_pagination(pagination) %}
   <ul class=pagination>
   {% if pagination.has_prev %}
-    <li><a href="{{ url_for_other_page(pagination.page - 1) }}">
+    <li><a href="{{url_for_other_page(pagination.page - 1)}}">
       &laquo;</a></li>
   {% else %}
     <li><span>&laquo;</span></li>
@@ -66,17 +66,17 @@
   {%- for page in pagination.iter_pages() %}
     {% if page %}
       {% if page != pagination.page %}
-        <li><a href="{{ url_for_other_page(page) }}">{{ page }}</a></li>
+        <li><a href="{{url_for_other_page(page)}}">{{page}}</a></li>
       {% else %}
-        <li class="active"><span>{{ page }}</span></li>
+        <li class="active"><span>{{page}}</span></li>
       {% endif %}
     {% else %}
       <li class="disabled"><span>…</span></li>
     {% endif %}
   {%- endfor %}
   {% if pagination.has_next %}
-    <li><a href="{{ url_for_other_page(pagination.page + 1)
-      }}">&raquo;</a></li>
+    <li><a href="{{url_for_other_page(pagination.page + 1)
+}}">&raquo;</a></li>
   {% else %}
     <li><span>&raquo;</span></li>
   {% endif %}
@@ -89,5 +89,4 @@
 
 {% endif %}
 
-
 {% endblock %}
diff --git a/app_data/odrs/templates/show.html b/app_data/odrs/templates/show.html
index 203ed17..54521e0 100644
--- a/app_data/odrs/templates/show.html
+++ b/app_data/odrs/templates/show.html
@@ -1,180 +1,180 @@
 {% extends "default.html" %}
-{% block title %}Review #{{ r.review_id }}{% endblock %}
+{% block title %}Review #{{r.review_id}}{% endblock %}
 
 {% block content %}
 
 <ul class="pagination">
-    <li><a href="/admin/review/{{ r.review_id - 1 }}">&lt;&lt;</a></li>
-    <li><a href="/admin/review/{{ r.review_id + 1 }}">&gt;&gt;</a></li>
+  <li><a href="{{url_for('.admin_show_review', review_id=r.review_id - 1)}}">&lt;&lt;</a></li>
+  <li><a href="{{url_for('.admin_show_review', review_id=r.review_id + 1)}}">&gt;&gt;</a></li>
 </ul>
 
-<form class="form" name="myform" action="/admin/modify/{{ r.review_id }}" method="POST">
+<form class="form" name="myform" action="{{url_for('admin_modify', review_id=r.review_id)}}" method="POST">
 
 {% if current_user.is_authenticated %}
 <div class="row form-group">
-    <div class="col-sm-1">
-        <strong>Actions</strong>
-    </div>
-    <div class="col-sm-11">
+  <div class="col-sm-1">
+    <strong>Actions</strong>
+  </div>
+  <div class="col-sm-11">
 {% if current_user.user_hash and not vote_exists %}
-      <a href="/admin/vote/{{r.review_id}}/up">
-        <button class="btn btn-action" type="button">Vote Up</button>
-      </a>
-      <a href="/admin/vote/{{r.review_id}}/down">
-        <button class="btn btn-action" type="button">Vote Down</button>
-      </a>
-      <a href="/admin/vote/{{r.review_id}}/meh">
-        <button class="btn btn-action" type="button">Meh</button>
-      </a>
-{% endif %}
-      <a href="/admin/delete/{{r.review_id}}">
-        <button class="btn btn-danger" type="button">Remove Forever</button>
-      </a>
-    </div>
+    <a href="{{url_for('.admin_vote', review_id=r.review_id, val_str='up')}}">
+    <button class="btn btn-action" type="button">Vote Up</button>
+    </a>
+    <a href="{{url_for('.admin_vote', review_id=r.review_id, val_str='down')}}">
+    <button class="btn btn-action" type="button">Vote Down</button>
+    </a>
+    <a href="{{url_for('.admin_vote', review_id=r.review_id, val_str='meh')}}">
+    <button class="btn btn-action" type="button">Meh</button>
+    </a>
+{% endif %}
+    <a href="{{url_for('.admin_delete', review_id=r.review_id)}}">
+    <button class="btn btn-danger" type="button">Remove Forever</button>
+    </a>
+  </div>
 </div>
 {% endif %}
 <div class="row form-group">
-    <div class="col-sm-1">
-        <strong>Application</strong>
-    </div>
-    <div class="col-sm-11">
-        <div class="input-group col-xs-12">
-            <textarea class="form-control" rows="1" name="app_id">{{ r.app_id }}</textarea>
-            {% if current_user.is_authenticated %}
-            <span class="input-group-addon btn btn-action">
-                <a href="/admin/show/app/{{ r.app_id }}">All</a>
-            </span>
-            {% endif %}
-        </div>
+  <div class="col-sm-1">
+    <strong>Application</strong>
+  </div>
+  <div class="col-sm-11">
+    <div class="input-group col-xs-12">
+      <textarea class="form-control" rows="1" name="app_id">{{r.app_id}}</textarea>
+{% if current_user.is_authenticated %}
+      <span class="input-group-addon btn btn-action">
+        <a href="{{url_for('.admin_show_app', app_id=r.app_id)}}">All</a>
+      </span>
+{% endif %}
     </div>
+  </div>
 </div>
 <div class="row form-group">
-    <div class="col-sm-1">
-        <strong>Rating</strong>
-    </div>
-    <div class="col-sm-1">
-        {{ format_rating(r.rating) }}
-    </div>
-    <div class="col-sm-1">
-        <strong>Karma</strong>
-    </div>
-    <div class="col-sm-3">
-      {{ r.karma_up }}&uarr;, {{ r.karma_down }}&darr;
-        {% if r.reported > 0 %}
-              (reported {{ r.reported }})
-        {% if current_user.is_authenticated %}
-              <a href="/admin/unreport/{{ r.review_id }}">
-                <button class="btn btn-action" type="button">Unreport</button>
-              </a>
-        {% endif %}
-        {% endif %}
-    </div>
-    <div class="col-sm-1">
-        <strong>Created</strong>
-    </div>
-    <div class="col-sm-5">
-      {{ format_timestamp(r.date_created) }}
-        {% if r.date_deleted %}
-              (deleted {{ format_timestamp(r.date_deleted) }})
-        {% if current_user.is_authenticated %}
-              <a href="/admin/unremove/{{ r.review_id }}">
-                <button class="btn btn-action" type="button">Unremove</button>
-              </a>
-        {% endif %}
-        {% endif %}
-    </div>
+  <div class="col-sm-1">
+    <strong>Rating</strong>
+  </div>
+  <div class="col-sm-1">
+    {{format_rating(r.rating)}}
+  </div>
+  <div class="col-sm-1">
+    <strong>Karma</strong>
+  </div>
+  <div class="col-sm-3">
+    {{r.karma_up}}&uarr;, {{r.karma_down}}&darr;
+{% if r.reported > 0 %}
+        (reported {{r.reported}})
+{% if current_user.is_authenticated %}
+        <a href="{{url_for('.admin_unreport', review_id=r.review_id)}}">
+        <button class="btn btn-action" type="button">Unreport</button>
+        </a>
+{% endif %}
+{% endif %}
+  </div>
+  <div class="col-sm-1">
+    <strong>Created</strong>
+  </div>
+  <div class="col-sm-5">
+    {{r.date_created}}
+{% if r.date_deleted %}
+    (deleted {{r.date_deleted}})
+{% if current_user.is_authenticated %}
+    <a href="{{url_for('.admin_unremove', review_id=r.review_id)}}">
+    <button class="btn btn-action" type="button">Unremove</button>
+    </a>
+{% endif %}
+{% endif %}
+  </div>
 </div>
 <div class="row form-group">
-    <div class="col-sm-1">
-        <strong>User Display</strong>
-    </div>
-    <div class="col-sm-11">
-        {% if r.user_display %}
-        {% if current_user.is_authenticated %}
-              <span class="help-block"><em>Names cannot be offensive or trademarks</em></span>
-        {% endif %}
-        <div class="input-group col-xs-12">
-            <textarea class="form-control" rows="1" name="user_display">{{ r.user_display }}</textarea>
-            {% if current_user.is_authenticated %}
-                <span class="input-group-addon btn btn-action">
-                  <a href="/admin/anonify/{{ r.review_id }}">Anonify</a>
-                </span>
-            {% endif %}
-            {% else %}
-                  <textarea class="form-control" rows="1" name="user_display"></textarea>
-            {% endif %}
-            {% if current_user.is_authenticated %}
-                <span class="input-group-addon btn btn-action">
-                  <a href="/admin/show/user/{{ r.user_hash }}">All</a>
-                </span>
-                <span class="input-group-addon btn btn-action">
-                  <a href="/admin/user_ban/{{ r.user_hash }}">Ban</a>
-                </span>
-            {% endif %}
-        </div>
+  <div class="col-sm-1">
+    <strong>User Display</strong>
+  </div>
+  <div class="col-sm-11">
+{% if r.user_display %}
+{% if current_user.is_authenticated %}
+        <span class="help-block"><em>Names cannot be offensive or trademarks</em></span>
+{% endif %}
+    <div class="input-group col-xs-12">
+      <textarea class="form-control" rows="1" name="user_display">{{r.user_display}}</textarea>
+{% if current_user.is_authenticated %}
+        <span class="input-group-addon btn btn-action">
+          <a href="{{url_for('.admin_anonify', 
review_id=r.review_id)}}/admin/anonify/{{r.review_id}}">Anonify</a>
+        </span>
+{% endif %}
+{% else %}
+          <textarea class="form-control" rows="1" name="user_display"></textarea>
+{% endif %}
+{% if current_user.is_authenticated %}
+        <span class="input-group-addon btn btn-action">
+          <a href="{{url_for('.admin_show_user', user_hash=r.user_hash)}}">All</a>
+        </span>
+        <span class="input-group-addon btn btn-action">
+          <a href="{{url_for('.admin_user_ban', user_hash=r.user_hash)}}">Ban</a>
+        </span>
+{% endif %}
     </div>
+  </div>
 </div>
 
 <div class="row form-group">
-    <div class="col-sm-1">
-        <strong>Locale</strong>
-    </div>
-    <div class="col-sm-5">
-        <div class="input-group col-xs-12">
-             <textarea class="form-control" rows="1" name="locale">{{ r.locale }}</textarea>
-            {% if current_user.is_authenticated %}
-            <span class="input-group-addon btn btn-action">
-              <a href="/admin/show/lang/{{ r.locale }}">All</a>
-            </span>
-            {% if not r.locale.startswith('en_') %}
-            <span class="input-group-addon btn btn-action">
-              <a href="/admin/englishify/{{ r.review_id }}">Englishify</a>
-            </span>
-            {% endif %}
-            {% endif %}
-        </div>
-    </div>
-    <div class="col-sm-1">
-        <strong>Version</strong>
-    </div>
-    <div class="col-sm-2">
-        <textarea class="form-control" rows="1" name="version">{{ r.version }}</textarea>
-    </div>
-    <div class="col-sm-1">
-        <strong>Distro</strong>
-    </div>
-    <div class="col-sm-2">
-      <textarea class="form-control" rows="1" name="distro">{{ r.distro }}</textarea>
+  <div class="col-sm-1">
+    <strong>Locale</strong>
+  </div>
+  <div class="col-sm-5">
+    <div class="input-group col-xs-12">
+       <textarea class="form-control" rows="1" name="locale">{{r.locale}}</textarea>
+{% if current_user.is_authenticated %}
+      <span class="input-group-addon btn btn-action">
+        <a href="{{url_for('.admin_show_lang', locale=r.locale)}}">All</a>
+      </span>
+{% if not r.locale.startswith('en_') %}
+      <span class="input-group-addon btn btn-action">
+        <a href="{{url_for('.admin_englishify', review_id=r.review_id)}}">Englishify</a>
+      </span>
+{% endif %}
+{% endif %}
     </div>
+  </div>
+  <div class="col-sm-1">
+    <strong>Version</strong>
+  </div>
+  <div class="col-sm-2">
+    <textarea class="form-control" rows="1" name="version">{{r.version}}</textarea>
+  </div>
+  <div class="col-sm-1">
+    <strong>Distro</strong>
+  </div>
+  <div class="col-sm-2">
+    <textarea class="form-control" rows="1" name="distro">{{r.distro}}</textarea>
+  </div>
 </div>
 <div class="row form-group">
-    <div class="col-sm-1">
-        <strong>Summary</strong>
-    </div>
-    <div class="col-sm-11">
-        {% if current_user.is_authenticated %}
-              <span class="help-block"><em>This should start with a capital letter and not end with a full 
stop</em></span>
-        {% endif %}
-        <textarea class="form-control" rows="1" name="summary">{{ r.summary }}</textarea>
-    </div>
+  <div class="col-sm-1">
+    <strong>Summary</strong>
+  </div>
+  <div class="col-sm-11">
+{% if current_user.is_authenticated %}
+    <span class="help-block"><em>This should start with a capital letter and not end with a full 
stop</em></span>
+{% endif %}
+    <textarea class="form-control" rows="1" name="summary">{{r.summary}}</textarea>
+  </div>
 </div>
 <div class="row form-group">
-    <div class="col-sm-1">
-        <strong>Description</strong>
-    </div>
-    <div class="col-sm-11">
-        {% if current_user.is_authenticated %}
-              <span class="help-block"><em>
-                This should start with a capital letter and each sentance should end with a full stop.<br/>
-                Please remove any incorrect content, and correct spelling, and grammar where required.
-              </em></span>
-        {% endif %}
-              <textarea class="form-control" rows="20" name="description">{{ r.description }}</textarea>
-              <br/>
-        {% if current_user.is_authenticated %}
-              <button class="btn btn-action" type="submit">Modify</button>
-        {% endif %}
-    </div>
+  <div class="col-sm-1">
+    <strong>Description</strong>
+  </div>
+  <div class="col-sm-11">
+{% if current_user.is_authenticated %}
+    <span class="help-block"><em>
+    This should start with a capital letter and each sentance should end with a full stop.<br/>
+    Please remove any incorrect content, and correct spelling, and grammar where required.
+    </em></span>
+{% endif %}
+    <textarea class="form-control" rows="20" name="description">{{r.description}}</textarea>
+    <br/>
+{% if current_user.is_authenticated %}
+    <button class="btn btn-action" type="submit">Modify</button>
+{% endif %}
+  </div>
 </div>
 </form>
 
diff --git a/app_data/odrs/templates/stats.html b/app_data/odrs/templates/stats.html
index d0f5ed2..a849362 100644
--- a/app_data/odrs/templates/stats.html
+++ b/app_data/odrs/templates/stats.html
@@ -5,10 +5,10 @@
 
 <h2>Web Service Statistics</h2>
 <table class="table table-hover table-responsive">
-{% for key, value in results_stats %}
+{% for key in results_stats %}
   <tr>
-    <td> {{ key }} </td>
-    <td> {{ value }} </td>
+    <td>{{key}}</td>
+    <td>{{results_stats[key]}}</td>
   </tr>
 {% endfor %}
 </table>
@@ -16,14 +16,14 @@
 <h2>Popularity By Page View</h2>
 <ol>
 {% for name, cnt in results_viewed %}
-  <li>{{ name.replace('.desktop', '') }} ({{ cnt }})</li>
+  <li>{{name.replace('.desktop', '')}} ({{cnt}})</li>
 {% endfor %}
 </ol>
 
 <h2>Popularity By Submitted Reviews</h2>
 <ol>
 {% for name, cnt in results_submitted %}
-  <li>{{ name.replace('.desktop', '') }} (<a href="admin/show/app/{{ name }}">{{ cnt }}</a>)</li>
+  <li>{{name.replace('.desktop', '')}} (<a href="{{url_for('.admin_show_app', 
app_id=name)}}">{{cnt}}</a>)</li>
 {% endfor %}
 </ol>
 
diff --git a/app_data/odrs/templates/users.html b/app_data/odrs/templates/users.html
index d100080..7544881 100644
--- a/app_data/odrs/templates/users.html
+++ b/app_data/odrs/templates/users.html
@@ -14,19 +14,19 @@
   </tr>
 {% for u in users_awesome %}
   <tr>
-    <td> {{ u.id }} </td>
-    <td> {{ u.karma }} </td>
+    <td>{{u.id}}</td>
+    <td>{{u.karma}}</td>
     <td>
 {% if current_user.is_authenticated %}
-      <a href="/admin/show/user/{{ u.user_hash }}">
-        <code>{{ u.user_hash }}</code>
+      <a href="{{url_for('.admin_show_user', user_hash=u.user_hash)}}">
+        <code>{{u.user_hash}}</code>
       </a>
 {% else %}
-      <code>{{ u.user_hash }}</code>
+      <code>{{u.user_hash}}</code>
 {% endif %}
     </td>
-    <td> {{ format_timestamp(u.date_created) }} </td>
-    <td> {{ u.is_banned }} </td>
+    <td>{{u.date_created}}</td>
+    <td>{{u.is_banned}}</td>
   </tr>
 {% endfor %}
 </table>
@@ -42,19 +42,19 @@
   </tr>
 {% for u in users_haters %}
   <tr>
-    <td> {{ u.id }} </td>
-    <td> {{ u.karma }} </td>
+    <td>{{u.id}}</td>
+    <td>{{u.karma}}</td>
     <td>
 {% if current_user.is_authenticated %}
-      <a href="/admin/show/user/{{ u.user_hash }}">
-        <code>{{ u.user_hash }}</code>
+      <a href="{{url_for('.admin_show_user', user_hash=u.user_hash)}}">
+        <code>{{u.user_hash}}</code>
       </a>
 {% else %}
-      <code>{{ u.user_hash }}</code>
+      <code>{{u.user_hash}}</code>
 {% endif %}
     </td>
-    <td> {{ format_timestamp(u.date_created) }} </td>
-    <td> {{ u.is_banned }} </td>
+    <td>{{u.date_created}}</td>
+    <td>{{u.is_banned}}</td>
   </tr>
 {% endfor %}
 </table>
diff --git a/app_data/odrs/tests/odrs_test.py b/app_data/odrs/tests/odrs_test.py
new file mode 100644
index 0000000..49a5abf
--- /dev/null
+++ b/app_data/odrs/tests/odrs_test.py
@@ -0,0 +1,523 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Richard Hughes <richard hughsie com>
+#
+# SPDX-License-Identifier: GPL-3.0+
+#
+# pylint: disable=fixme,too-many-public-methods,line-too-long,too-many-lines
+# pylint: disable=too-many-instance-attributes,wrong-import-position
+
+import os
+import json
+import sys
+import unittest
+import tempfile
+
+# allows us to run this from the project root
+sys.path.append(os.path.realpath('.'))
+
+from odrs.util import _get_user_key
+
+class OdrsTest(unittest.TestCase):
+
+    def setUp(self):
+
+        # create new database
+        self.db_fd, self.db_filename = tempfile.mkstemp()
+        self.db_uri = 'sqlite:///' + self.db_filename
+        self.user_hash = 'deadbeef348c0f88529f3bfd937ec1a5d90aefc7'
+
+        # write out custom settings file
+        self.cfg_fd, self.cfg_filename = tempfile.mkstemp()
+        with open(self.cfg_filename, 'w') as cfgfile:
+            cfgfile.write('\n'.join([
+                "SQLALCHEMY_DATABASE_URI = '%s'" % self.db_uri,
+                "SQLALCHEMY_TRACK_MODIFICATIONS = False",
+                "SECRET_KEY = 'not-secret4'",
+                "ODRS_REVIEWS_SECRET = '1'",
+                "DEBUG = True",
+                ]))
+
+        # create instance
+        import odrs
+        from odrs import db
+        from odrs.dbutils import init_db
+        self.app = odrs.app.test_client()
+        odrs.app.config.from_pyfile(self.cfg_filename)
+        with odrs.app.app_context():
+            init_db(db)
+
+        # assign user_hash to this account
+        self.login()
+        rv = self.app.post('/admin/moderator/1/modify_by_admin', data=dict(
+            is_enabled=True,
+            locales='en',
+            user_hash=self.user_hash,
+        ), follow_redirects=True)
+        assert b'Updated profile' in rv.data, rv.data
+        self.logout()
+
+    def tearDown(self):
+        os.close(self.db_fd)
+        os.unlink(self.db_filename)
+        os.close(self.cfg_fd)
+        os.unlink(self.cfg_filename)
+
+    def _login(self, username, password='Pa$$w0rd'):
+        return self.app.post('/login', data=dict(
+            username=username,
+            password=password
+        ), follow_redirects=True)
+
+    def _logout(self):
+        return self.app.get('/logout', follow_redirects=True)
+
+    def login(self, username='admin', password='Pa$$w0rd'):
+        rv = self._login(username, password)
+        assert b'Logged in' in rv.data, rv.data
+        assert b'/admin/show/reported' in rv.data, rv.data
+        assert b'Incorrect username' not in rv.data, rv.data
+
+    def logout(self):
+        rv = self._logout()
+        assert b'Logged out' in rv.data, rv.data
+        assert b'/admin/show/reported' not in rv.data, rv.data
+
+    def test_admin_graphs(self):
+
+        self.review_submit()
+        self.review_fetch()
+        self.review_fetch()
+        self.review_fetch()
+
+        self.login()
+        rv = self.app.get('/admin/graph_month')
+        assert b'Chart.js' in rv.data, rv.data
+        assert b'0, 1' in rv.data, rv.data
+
+        rv = self.app.get('/admin/graph_year')
+        assert b'Chart.js' in rv.data, rv.data
+        assert b'0, 1' in rv.data, rv.data
+
+        rv = self.app.get('/admin/distros')
+        assert b'Chart.js' in rv.data, rv.data
+
+        rv = self.app.get('/admin/stats')
+        assert b'Active reviews</td>\n    <td>1</td>' in rv.data, rv.data
+
+        rv = self.app.get('/admin/users/all')
+        assert b'Haters Gonna Hate' in rv.data, rv.data
+
+    def test_admin_unreport(self):
+
+        self.review_submit()
+        self.review_report()
+
+        self.login()
+        rv = self.app.get('/admin/unreport/1', follow_redirects=True)
+        assert b'Review unreported' in rv.data, rv.data
+
+    def test_admin_review(self):
+
+        rv = self._review_submit(locale='in_IN')
+        assert b'"success": true' in rv.data, rv.data
+
+        self.login()
+        rv = self.app.get('/admin/review/1')
+        assert b'Inkscape has been a essential part of my workflow for many years' in rv.data, rv.data
+        assert b'Somebody Important' in rv.data, rv.data
+        assert b'Fedora' in rv.data, rv.data
+        rv = self.app.post('/admin/modify/1', data=dict(
+            distro='Ubuntu',
+        ), follow_redirects=True)
+        assert b'Inkscape has been a essential part of my workflow for many years' in rv.data, rv.data
+        assert b'Ubuntu' in rv.data, rv.data
+
+        rv = self.app.get('/admin/englishify/1', follow_redirects=True)
+        assert b'en_IN' in rv.data, rv.data
+
+        rv = self.app.get('/admin/anonify/1', follow_redirects=True)
+        assert b'Somebody Important' not in rv.data, rv.data
+
+        rv = self.app.get('/admin/vote/1/down', follow_redirects=True)
+        assert b'Recorded vote' in rv.data, rv.data
+
+        # delete
+        rv = self.app.get('/admin/delete/1', follow_redirects=True)
+        assert b'Confirm Removal?' in rv.data, rv.data
+        rv = self.app.get('/admin/delete/1/force', follow_redirects=True)
+        assert b'Deleted review' in rv.data, rv.data
+        rv = self.app.get('/admin/review/1')
+        assert b'no review with that ID' in rv.data, rv.data
+
+    def _admin_moderator_add(self, username='dave', password='foobarbaz123.', email='dave dave com'):
+
+        return self.app.post('/admin/moderator/add', data=dict(
+            password_new=password,
+            username_new=username,
+            display_name='Dave',
+            email=email,
+        ), follow_redirects=True)
+
+    def test_admin_add_moderator(self):
+
+        self.login()
+
+        # bad values
+        rv = self._admin_moderator_add(username='1')
+        assert b'Username invalid' in rv.data, rv.data
+        rv = self._admin_moderator_add(password='foo')
+        assert b'The password is too short' in rv.data, rv.data
+        rv = self._admin_moderator_add(password='foobarbaz')
+        assert b'requires at least one non-alphanumeric' in rv.data, rv.data
+        rv = self._admin_moderator_add(email='foo')
+        assert b'Invalid email address' in rv.data, rv.data
+
+        # good values
+        rv = self._admin_moderator_add()
+        assert b'Added user' in rv.data, rv.data
+
+        # duplicate
+        rv = self._admin_moderator_add()
+        assert b'Already a entry with that username' in rv.data, rv.data
+
+        # remove
+        rv = self.app.get('admin/moderator/2/delete', follow_redirects=True)
+        assert b'Deleted user' in rv.data, rv.data
+
+    def test_admin_show_reviews(self):
+
+        self.review_submit()
+        self.login()
+        rv = self.app.get('/admin/show/all')
+        assert b'An essential ' in rv.data, rv.data
+
+        rv = self.app.get('/admin/show/unmoderated')
+        assert b'An essential ' in rv.data, rv.data
+
+        rv = self.app.get('/admin/show/lang/en_US')
+        assert b'An essential ' in rv.data, rv.data
+        rv = self.app.get('/admin/show/lang/fr_FR')
+        assert b'An essential ' not in rv.data, rv.data
+
+    def test_admin_moderators(self):
+
+        self.login()
+        rv = self.app.get('/admin/moderators/all')
+        assert self.user_hash.encode() in rv.data, rv.data
+
+    def test_api_submit_when_banned(self):
+
+        # submit abusive review
+        self.review_submit()
+
+        # add user to the ban list
+        self.login()
+        rv = self.app.get('/admin/user_ban/{}'.format(self.user_hash), follow_redirects=True)
+        assert b'Banned user' in rv.data, rv.data
+        self.logout()
+
+        # try to submit another review
+        rv = self._review_submit()
+        assert b'account has been disabled due to abuse' in rv.data, rv.data
+
+
+    def test_login_logout(self):
+
+        # test logging in and out
+        rv = self._login('admin', 'Pa$$w0rd')
+        assert b'/admin/show/reported' in rv.data, rv.data
+        rv = self._logout()
+        rv = self._login('admin', 'Pa$$w0rd')
+        assert b'/admin/show/reported' in rv.data, rv.data
+        rv = self._logout()
+        assert b'/admin/show/reported' not in rv.data, rv.data
+        rv = self._login('adminx', 'default')
+        assert b'Incorrect username' in rv.data, rv.data
+        rv = self._login('admin', 'defaultx')
+        assert b'Incorrect password' in rv.data, rv.data
+
+    @staticmethod
+    def run_cron(fn='test.json'):
+
+        from odrs import app
+        from cron import _regenerate_ratings
+        with app.test_request_context():
+            _regenerate_ratings(fn)
+
+    def test_nologin_required(self):
+
+        # all these are viewable without being logged in
+        uris = ['/',
+                '/privacy',
+                #'/1.0/reviews/api/all',
+                ]
+        for uri in uris:
+            rv = self.app.get(uri, follow_redirects=True)
+            assert b'favicon.ico' in rv.data, rv.data
+            assert b'Error!' not in rv.data, rv.data
+
+    def _review_submit(self, app_id=None, locale='en_US', distro='Fedora',
+                       version='2:1.2.3~dsg',
+                       summary=' An essential part of my daily workflow',
+                       user_hash=None, user_skey=None,
+                       user_display='Somebody Important'):
+        if not app_id:
+            app_id = 'inkscape.desktop'
+        if not user_hash:
+            user_hash = self.user_hash
+        if not user_skey:
+            user_skey = _get_user_key(user_hash, app_id)
+        # upload a review
+        data = {'app_id': app_id,
+                'locale': locale,
+                'summary': summary,
+                'description': 'Inkscape has been a essential part of my workflow for many years now.',
+                'user_hash': user_hash,
+                'user_skey': user_skey,
+                'user_display': user_display,
+                'distro': distro,
+                'rating': 100,
+                'version': version}
+        return self.app.post('/1.0/reviews/api/submit', data=json.dumps(data), follow_redirects=True)
+
+    def review_submit(self, app_id=None, user_hash=None):
+        rv = self._review_submit(app_id=app_id, user_hash=user_hash)
+        assert b'"success": true' in rv.data, rv.data
+
+    def _review_fetch(self,
+                      app_id='inkscape.desktop',
+                      user_hash=None,
+                      locale='en_US',
+                      distro='Fedora',
+                      compat_ids=None,
+                      version='1.2.3'):
+        if not user_hash:
+            user_hash = self.user_hash
+        # fetch some reviews
+        data = {'app_id': app_id,
+                'user_hash': user_hash,
+                'locale': locale,
+                'distro': distro,
+                'limit': 5,
+                'version': version}
+        if compat_ids:
+            data['compat_ids'] = compat_ids
+        return self.app.post('/1.0/reviews/api/fetch', data=json.dumps(data), follow_redirects=True)
+
+    def review_fetch(self):
+        rv = self._review_fetch(app_id='inkscape.desktop')
+        assert b'An essential part of my daily workflow' in rv.data, rv.data
+
+    def test_api_dumping_all(self):
+
+        rv = self.app.get('/1.0/reviews/api/all')
+        assert rv.data == b'[]', rv.data
+        self.review_submit()
+        rv = self.app.get('/1.0/reviews/api/all')
+        assert b'Somebody Important' in rv.data, rv.data
+
+    def test_api_moderate_locale(self):
+
+        rv = self.app.get('/1.0/reviews/api/moderate/FIXMEuserhash/en_GB')
+        assert rv.data == b'[]', rv.data
+        self.review_submit()
+        rv = self.app.get('/1.0/reviews/api/moderate/FIXMEuserhash/en_GB')
+        assert b'Somebody Important' in rv.data, rv.data
+        rv = self.app.get('/1.0/reviews/api/moderate/FIXMEuserhash/fr_FR')
+        assert rv.data == b'[]', rv.data
+
+    def test_api_fetch_no_results(self):
+
+        # get the skey back for an app with no reviews
+        rv = self._review_fetch(app_id='not-going-to-exist.desktop')
+        assert b'An essential part of my daily workflow' not in rv.data, rv.data
+        assert b'user_skey' in rv.data, rv.data
+
+    def test_api_fetch_compat_id(self):
+
+        self.review_submit()
+
+        # get the reviews back for the app using compat IDs
+        rv = self._review_fetch(app_id='foo.desktop', compat_ids=['inkscape.desktop'])
+        assert b'An essential part of my daily workflow' in rv.data, rv.data
+        assert b'user_skey' in rv.data, rv.data
+
+    def review_upvote(self):
+        data = {'review_id': 1,
+                'app_id': 'inkscape.desktop',
+                'user_hash': self.user_hash,
+                'user_skey': _get_user_key(self.user_hash, 'inkscape.desktop')}
+        return self.app.post('/1.0/reviews/api/upvote', data=json.dumps(data))
+
+    def review_report(self):
+        data = {'review_id': 1,
+                'app_id': 'inkscape.desktop',
+                'user_hash': self.user_hash,
+                'user_skey': _get_user_key(self.user_hash, 'inkscape.desktop')}
+        return self.app.post('/1.0/reviews/api/report', data=json.dumps(data))
+
+    def test_api_upvote(self):
+
+        # does not exist
+        rv = self.review_upvote()
+        assert b'invalid review ID' in rv.data, rv.data
+
+        # first upvote
+        self.review_submit()
+        rv = self.review_upvote()
+        assert b'success": true' in rv.data, rv.data
+        assert b'voted #1 1' in rv.data, rv.data
+
+        # duplicate upvote
+        rv = self.review_upvote()
+        assert b'success": false' in rv.data, rv.data
+        assert b'already voted on this app' in rv.data, rv.data
+
+        # check vote_id is set
+        rv = self._review_fetch(app_id='inkscape.desktop')
+        assert b'vote_id": 1' in rv.data, rv.data
+
+    def test_api_report(self):
+
+        # submit and verify
+        self.review_submit()
+        self.review_fetch()
+
+        # should not appear again
+        rv = self.review_report()
+        assert b'success": true' in rv.data, rv.data
+        assert b'voted #1 -5' in rv.data, rv.data
+        rv = self._review_fetch(app_id='inkscape.desktop')
+        assert b'An essential part of my daily workflow' not in rv.data, rv.data
+
+        # duplicate upvote
+        rv = self.review_upvote()
+        assert b'success": false' in rv.data, rv.data
+        assert b'already voted on this app' in rv.data, rv.data
+
+    def test_api_app_rating(self):
+
+        # nothing
+        rv = self.app.get('/1.0/reviews/api/ratings/not-going-to-exist.desktop')
+        assert rv.data == b'[]', rv.data
+
+        # something
+        self.review_submit()
+        rv = self.app.get('/1.0/reviews/api/ratings/inkscape.desktop')
+        assert b'star1": 0' in rv.data, rv.data
+        assert b'star5": 1' in rv.data, rv.data
+        assert b'total": 1' in rv.data, rv.data
+
+        # all
+        self.review_submit(user_hash='0000000000000000000000000000000000000000')
+        rv = self.app.get('/1.0/reviews/api/ratings')
+        assert b'inkscape.desktop' in rv.data, rv.data
+        assert b'star1": 0' in rv.data, rv.data
+        assert b'star5": 2' in rv.data, rv.data
+        assert b'total": 2' in rv.data, rv.data
+
+    def test_api_remove(self):
+
+        self.review_submit()
+
+        # wrong app_id
+        data = {'review_id': 1,
+                'app_id': 'dave.desktop',
+                'user_hash': self.user_hash,
+                'user_skey': _get_user_key(self.user_hash, 'dave.desktop')}
+        rv = self.app.post('/1.0/reviews/api/remove', data=json.dumps(data))
+        assert b'the app_id is invalid' in rv.data, rv.data
+
+        # wrong user_hash
+        data = {'review_id': 1,
+                'app_id': 'inkscape.desktop',
+                'user_hash': _get_user_key(self.user_hash, 'inkscape.desktop'),
+                'user_skey': _get_user_key(self.user_hash, 'inkscape.desktop')}
+        rv = self.app.post('/1.0/reviews/api/remove', data=json.dumps(data))
+        assert b'no review' in rv.data, rv.data
+
+        # wrong user_skey
+        data = {'review_id': 1,
+                'app_id': 'inkscape.desktop',
+                'user_hash': self.user_hash,
+                'user_skey': self.user_hash}
+        rv = self.app.post('/1.0/reviews/api/remove', data=json.dumps(data))
+        assert b'invalid user_skey' in rv.data, rv.data
+
+        # delete a review
+        data = {'review_id': 1,
+                'app_id': 'inkscape.desktop',
+                'user_hash': self.user_hash,
+                'user_skey': _get_user_key(self.user_hash, 'inkscape.desktop')}
+        rv = self.app.post('/1.0/reviews/api/remove', data=json.dumps(data))
+        assert b'removed review #1' in rv.data, rv.data
+
+    def test_api_submit(self):
+
+        # upload a report
+        rv = self._review_submit()
+        assert b'"success": true' in rv.data, rv.data
+
+        # upload a 2nd report
+        #rv = self._review_submit(app_id='gimp.desktop')        # FIXME supposed to fail!
+        #assert b'"success": true' in rv.data, rv.data
+
+        # upload a duplicate report
+        rv = self._review_submit()
+        assert b'success": false' in rv.data, rv.data
+        assert b'already reviewed this app' in rv.data, rv.data
+
+        # upload an invalid report
+        rv = self._review_submit(summary='<html>foo</html>')
+        assert b'success": false' in rv.data, rv.data
+        assert b'is not a valid string' in rv.data, rv.data
+        rv = self._review_submit(summary='')
+        assert b'success": false' in rv.data, rv.data
+        assert b'missing data' in rv.data, rv.data
+
+        # get the review back
+        rv = self.app.get('/1.0/reviews/api/app/inkscape.desktop')
+        assert b'Somebody Important' in rv.data, rv.data
+        assert b'An essential part of my daily workflow' in rv.data, rv.data
+        assert b'user_skey' not in rv.data, rv.data
+
+        # get the review back with skey
+        rv = self.app.get('/1.0/reviews/api/app/inkscape.desktop/{}'.format(self.user_hash))
+        assert b'An essential part of my daily workflow' in rv.data, rv.data
+        assert b'user_skey' in rv.data, rv.data
+
+        # get the reviews back for the app
+        rv = self._review_fetch(distro='Ubuntu', version='1.2.4')
+        assert b'An essential part of my daily workflow' in rv.data, rv.data
+        assert b'user_skey' in rv.data, rv.data
+
+        # FIXME: ban user then try to submit a review
+
+    def test_fail_when_login_required(self):
+
+        # all these are an error when not logged in
+        uris = ['/admin/graph_month',
+                '/admin/graph_year',
+                '/admin/stats',
+                '/admin/user_ban/1',
+                '/admin/show/reported',
+                '/admin/show/unmoderated',
+                '/admin/users/all',
+                '/admin/moderators/all',
+                '/admin/distros']
+        for uri in uris:
+            rv = self.app.get(uri)
+            assert b'favicon.ico' in rv.data, rv.data
+            assert b'Permission denied' in rv.data, (uri, rv.data)
+
+        # POST only
+        uris = ['/admin/modify/1']
+        for uri in uris:
+            rv = self.app.post(uri)
+            assert b'favicon.ico' in rv.data, rv.data
+            assert b'Permission denied' in rv.data, rv.data
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/app_data/odrs/tests/util_test.py b/app_data/odrs/tests/util_test.py
new file mode 100644
index 0000000..97baa79
--- /dev/null
+++ b/app_data/odrs/tests/util_test.py
@@ -0,0 +1,55 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Richard Hughes <richard hughsie com>
+#
+# SPDX-License-Identifier: GPL-3.0+
+#
+# pylint: disable=no-self-use,protected-access,wrong-import-position
+
+import os
+import sys
+
+import unittest
+
+# allows us to run this from the project root
+sys.path.append(os.path.realpath('.'))
+
+from odrs.util import json_success, json_error, _locale_is_compatible
+from odrs.util import _get_user_key, _password_hash
+from odrs.util import _sanitised_version, _sanitised_summary, _sanitised_description
+
+class UtilTest(unittest.TestCase):
+
+    def test_sanitise(self):
+
+        self.assertEqual(_sanitised_version('16.12.3'), '16.12.3')
+        self.assertEqual(_sanitised_version('0:1.2.3+rh'), '1.2.3')
+        self.assertEqual(_sanitised_version('16.11.0~ds0'), '16.11.0')
+        self.assertEqual(_sanitised_summary('   not sure why people include.   '), 'not sure why people 
include')
+        self.assertEqual(_sanitised_description('   this is awesome :) !!   '), 'this is awesome !!')
+
+    def test_response(self):
+
+        self.assertEqual(str(json_success('ok')), '<Response 40 bytes [200 OK]>')
+        self.assertEqual(str(json_error('nok')), '<Response 42 bytes [400 BAD REQUEST]>')
+
+    def test_locale(self):
+
+        self.assertTrue(_locale_is_compatible('en_GB', 'en_GB'))
+        self.assertTrue(_locale_is_compatible('en_GB', 'en_AU'))
+        self.assertTrue(_locale_is_compatible('en_GB', 'C'))
+        self.assertTrue(_locale_is_compatible('C', 'en_GB'))
+        self.assertFalse(_locale_is_compatible('fr_FR', 'en_GB'))
+
+    def test_user_key(self):
+
+        os.environ['ODRS_REVIEWS_SECRET'] = '1'
+        self.assertEqual(_get_user_key('foo', 'gimp.desktop'), '74e9a6c58a608c88104efab48cc76beb271af1f1')
+
+    def test_legacy_hash(self):
+
+        self.assertEqual(_password_hash('foo'), '9cab340b3184a1f792d6629806703aed450ecd48')
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/app_data/odrs/util.py b/app_data/odrs/util.py
index 92d96bf..d00f0b6 100644
--- a/app_data/odrs/util.py
+++ b/app_data/odrs/util.py
@@ -6,8 +6,11 @@
 # SPDX-License-Identifier: GPL-3.0+
 
 import json
+import hashlib
 
-from flask import Response
+from sqlalchemy import text
+
+from flask import Response, abort, flash
 
 def json_success(msg=None, errcode=200):
     """ Success handler: JSON output """
@@ -18,7 +21,7 @@ def json_success(msg=None, errcode=200):
     dat = json.dumps(item, sort_keys=True, indent=4, separators=(',', ': '))
     return Response(response=dat,
                     status=errcode, \
-                    mimetype="application/json")
+                    mimetype='application/json')
 
 def json_error(msg=None, errcode=400):
     """ Error handler: JSON output """
@@ -29,4 +32,127 @@ def json_error(msg=None, errcode=400):
     dat = json.dumps(item, sort_keys=True, indent=4, separators=(',', ': '))
     return Response(response=dat,
                     status=errcode, \
-                    mimetype="application/json")
+                    mimetype='application/json')
+
+def _error_permission_denied(msg=None):
+    flash('Permission denied: %s' % msg)
+    abort(401)
+
+def _error_internal(msg=None):
+    flash('Internal error: %s' % msg)
+    abort(400)
+
+def _get_datestr_from_dt(when):
+    return int('%04i%02i%02i' % (when.year, when.month, when.day))
+
+def _get_user_key(user_hash, app_id):
+    from odrs import app
+    salt = app.config['ODRS_REVIEWS_SECRET']
+    key = 'invalid'
+    try:
+        key = hashlib.sha1(salt.encode('utf-8') +
+                           user_hash.encode('utf-8') +
+                           app_id.encode('utf-8')).hexdigest()
+    except UnicodeEncodeError as e:
+        print('invalid input: %s,%s: %s' % (user_hash, app_id, str(e)))
+    return key
+
+def _eventlog_add(user_addr=None,
+                  user_hash=None,
+                  app_id=None,
+                  message=None,
+                  important=False):
+    """ Adds a warning to the event log """
+    from .models import Event
+    from odrs import db
+    db.session.add(Event(user_addr, user_hash, app_id, message, important))
+    db.session.commit()
+
+def _get_rating_for_app_id(app_id, min_total=1):
+    """ Gets the ratings information for the application """
+    from odrs import db
+    stmt = text('SELECT COUNT(*) total,'
+                '       SUM(rating = 0) star0,'
+                '       SUM(rating = 20) star1,'
+                '       SUM(rating = 40) star2,'
+                '       SUM(rating = 60) star3,'
+                '       SUM(rating = 80) star4,'
+                '       SUM(rating = 100) star5 '
+                'FROM reviews WHERE app_id=:app_id;')
+    res = db.session.execute(stmt.bindparams(app_id=app_id)).fetchone() # pylint: disable=no-member
+    if not res:
+        return []
+    item = {}
+    item['total'] = res[0]
+    if item['total'] < min_total:
+        return []
+    for i in range(6):
+        if res[i + 1]:
+            item['star%i' % i] = int(res[i + 1])
+        else:
+            item['star%i' % i] = 0
+    return item
+
+def _password_hash(value):
+    """ Generate a legacy salted hash of the password string """
+    salt = 'odrs%%%'
+    return hashlib.sha1(salt.encode('utf-8') + value.encode('utf-8')).hexdigest()
+
+def _sanitised_input(val):
+
+    # remove trailing whitespace
+    val = val.strip()
+
+    # fix up style issues
+    val = val.replace('!!!', '!')
+    val = val.replace(':)', '')
+    val = val.replace('  ', ' ')
+
+    return val
+
+def _sanitised_summary(val):
+    val = _sanitised_input(val)
+    if val.endswith('.'):
+        val = val[:len(val)-1]
+    return val
+
+def _sanitised_description(val):
+    return _sanitised_input(val)
+
+def _sanitised_version(val):
+
+    # remove epoch
+    idx = val.find(':')
+    if idx != -1:
+        val = val[idx+1:]
+
+    # remove distro addition
+    idx = val.find('+')
+    if idx != -1:
+        val = val[:idx]
+    idx = val.find('~')
+    if idx != -1:
+        val = val[:idx]
+
+    return val
+
+def _locale_is_compatible(l1, l2):
+    """ Returns True if the locale is compatible """
+
+    # trivial case
+    if l1 == l2:
+        return True
+
+    # language code matches
+    lang1 = l1.split('_')[0]
+    lang2 = l2.split('_')[0]
+    if lang1 == lang2:
+        return True
+
+    # LANG=C
+    en_langs = ['C', 'en']
+    if lang1 in en_langs and lang2 in en_langs:
+        return True
+
+    # perhaps include other locale quirks here?
+    return False
diff --git a/app_data/odrs/views.py b/app_data/odrs/views.py
index 163bba7..233434b 100644
--- a/app_data/odrs/views.py
+++ b/app_data/odrs/views.py
@@ -3,158 +3,92 @@
 #
 # pylint: disable=invalid-name,missing-docstring
 #
-# Copyright (C) 2015-2017 Richard Hughes <richard hughsie com>
+# Copyright (C) 2015-2019 Richard Hughes <richard hughsie com>
 #
 # SPDX-License-Identifier: GPL-3.0+
 
-import json
 import os
-import hashlib
-import math
 
-from flask import request, url_for, redirect, flash, render_template, send_from_directory, Response
+from flask import request, url_for, redirect, flash, render_template, send_from_directory, g
 from flask_login import login_user, logout_user
 
-from odrs import app, get_db
+from odrs import app, db
 
-from .db import CursorError
-from .models import Review
-from .util import json_success, json_error
+from .models import Moderator
 
-def _get_user_key(user_hash, app_id):
-    salt = os.environ['ODRS_REVIEWS_SECRET']
-    key = 'invalid'
-    try:
-        key = hashlib.sha1(salt.encode('utf-8') +
-                           user_hash.encode('utf-8') +
-                           app_id.encode('utf-8')).hexdigest()
-    except UnicodeEncodeError as e:
-        print("invalid input: %s,%s: %s" % (user_hash, app_id, str(e)))
-    return key
+@app.context_processor
+def _utility_processor():
+    def format_rating(rating):
+        nr_stars = int(rating / 20)
+        tmp = ''
+        for _ in range(0, nr_stars):
+            tmp += '★'
+        for _ in range(0, 5 - nr_stars):
+            tmp += '☆'
+        return tmp
 
-def _get_client_address():
-    """ Gets user IP address """
-    if request.headers.getlist("X-Forwarded-For"):
-        return request.headers.getlist("X-Forwarded-For")[0]
-    return request.remote_addr
+    def format_truncate(tmp, length):
+        if len(tmp) <= length:
+            return tmp
+        return tmp[:length] + '…'
 
-def _locale_is_compatible(l1, l2):
-    """ Returns True if the locale is compatible """
+    def url_for_other_page(page):
+        args = request.view_args.copy()
+        args['page'] = page
+        return url_for(request.endpoint, **args)
 
-    # trivial case
-    if l1 == l2:
-        return True
-
-    # language code matches
-    lang1 = l1.split('_')[0]
-    lang2 = l2.split('_')[0]
-    if lang1 == lang2:
-        return True
-
-    # LANG=C
-    en_langs = ['C', 'en']
-    if lang1 in en_langs and lang2 in en_langs:
-        return True
-
-    # FIXME: include other locale quirks here
-    return False
-
-def _get_review_score(review, item):
-    """ Gets a review score given certain parameters """
-    ku = review.karma_up
-    kd = review.karma_down
-
-    # hardcode some penalties
-    if review.version != item['version']:
-        kd = kd + 1
-    if review.distro != item['distro']:
-        kd = kd + 1
-
-    # algorithm from http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
-    wilson = 0
-    if ku > 0 or kd > 0:
-        wilson = ((ku + 1.9208) / (ku + kd) -
-                  1.96 * math.sqrt((ku * kd) / (ku + kd) + 0.9604) /
-                  (ku + kd)) / (1 + 3.8416 / (ku + kd))
-        wilson *= 100
-    return int(wilson)
-
-def _sanitised_input(val):
-
-    # remove trailing whitespace
-    val = val.strip()
-
-    # fix up style issues
-    val = val.replace('!!!', '!')
-    val = val.replace(':)', '')
-    val = val.replace('  ', ' ')
-
-    return val
-
-def _sanitised_summary(val):
-    val = _sanitised_input(val)
-    if val.endswith('.'):
-        val = val[:len(val)-1]
-    return val
-
-def _sanitised_description(val):
-    return _sanitised_input(val)
-
-def _sanitised_version(val):
-
-    # remove epoch
-    idx = val.find(':')
-    if idx != -1:
-        val = val[idx+1:]
-
-    # remove distro addition
-    idx = val.find('+')
-    if idx != -1:
-        val = val[:idx]
-    idx = val.find('~')
-    if idx != -1:
-        val = val[:idx]
-
-    return val
+    return dict(format_rating=format_rating,
+                format_truncate=format_truncate,
+                url_for_other_page=url_for_other_page)
 
 @app.route('/login', methods=['GET', 'POST'])
-def login():
+def odrs_login():
     if request.method != 'POST':
         return render_template('login.html')
-    try:
-        db = get_db()
-        user = db.moderators.get_by_username_password(request.form['username'],
-                                                      request.form['password'])
-    except CursorError as e:
-        flash(str(e))
-        return render_template('error.html'), 503
+    user = db.session.query(Moderator).filter(Moderator.username == request.form['username']).first()
     if not user:
-        flash('Credentials are not valid.')
-        return redirect(url_for('.login'))
+        flash('Incorrect username')
+        return redirect(url_for('.odrs_login'))
+    if not user.verify_password(request.form['password']):
+        flash('Incorrect password')
+        return redirect(url_for('.odrs_login'))
     login_user(user, remember=False)
-    flash('Logged in successfully.')
-    return redirect(url_for('.index'))
+    g.user = user
+    flash('Logged in')
+    return redirect(url_for('.odrs_index'))
 
-@app.route("/logout")
-def logout():
+@app.route('/logout')
+def odrs_logout():
     logout_user()
-    flash('Logged out successfully.')
-    return redirect(url_for('.index'))
+    flash('Logged out.')
+    return redirect(url_for('.odrs_index'))
+
+@app.errorhandler(400)
+def _error_internal(msg=None, errcode=400):
+    """ Error handler: Internal """
+    flash('Internal error: %s' % msg)
+    return render_template('error.html'), errcode
+
+@app.errorhandler(401)
+def _error_permission_denied(msg=None):
+    """ Error handler: Permission Denied """
+    flash('Permission denied: %s' % msg)
+    return render_template('error.html'), 401
 
 @app.errorhandler(404)
-def error_page_not_found(msg=None):
+def odrs_error_page_not_found(msg=None):
     """ Error handler: File not found """
     flash(msg)
     return render_template('error.html'), 404
 
 @app.route('/')
-def index():
+def odrs_index():
     """ start page """
     return render_template('index.html')
 
 @app.route('/privacy')
-def privacy():
-    """ privacy page """
+def odrs_privacy():
+    """ odrs_privacy page """
     return render_template('privacy.html')
 
 @app.route('/oars')
@@ -163,431 +97,6 @@ def oars_index():
     return render_template('oars.html')
 
 @app.route('/<path:resource>')
-def static_resource(resource):
+def odrs_static_resource(resource):
     """ Return a static image or resource """
-    return send_from_directory("%s/app/static/" % os.environ['HOME'], os.path.basename(resource))
-
-@app.errorhandler(401)
-def _error_permission_denied(msg=None):
-    """ Error handler: Permission Denied """
-    return json_error(msg, 401)
-
-def _check_str(val):
-    """ Return with success if the summary and description """
-    if val.find('<') != -1:
-        return False
-    if val.find('<') != -1:
-        return False
-    return True
-
-@app.route('/1.0/reviews/api/submit', methods=['POST'])
-def submit():
-    """
-    Submits a new review.
-    """
-    try:
-        item = json.loads(request.data.decode('utf8'))
-    except ValueError as e:
-        return json_error(str(e))
-    required_fields = ['app_id', 'locale', 'summary', 'description',
-                       'user_hash', 'version', 'distro', 'rating',
-                       'user_display']
-    for key in required_fields:
-        if not key in item:
-            return json_error('invalid data, expected %s' % key)
-        if item[key] is None:
-            return json_error('missing data, expected %s' % key)
-
-    # check format
-    if not len(item['user_hash']) == 40:
-        return json_error('the user_hash is invalid')
-
-    # check fields for markup and length
-    if len(item['summary']) > 70:
-        return json_error('summary is too long')
-    if len(item['description']) > 3000:
-        return json_error('description is too long')
-    for key in ['summary', 'description']:
-        if not _check_str(item[key]):
-            return json_error('%s is not a valid string' % key)
-    try:
-
-        # user has already reviewed
-        db = get_db()
-        if db.reviews.exists(item['app_id'], item['user_hash']):
-            db.eventlog.warn(_get_client_address(),
-                             item['user_hash'],
-                             item['app_id'],
-                             "already reviewed")
-            return json_error('already reviewed this app')
-
-        # check user has not been banned
-        user = db.users.get_by_hash(item['user_hash'])
-        if user and user.is_banned:
-            return json_error('account has been disabled due to abuse')
-
-        # create new
-        review = Review()
-        review.app_id = item['app_id']
-        review.locale = item['locale']
-        review.summary = _sanitised_summary(item['summary'])
-        review.description = _sanitised_description(item['description'])
-        review.user_hash = item['user_hash']
-        review.version = _sanitised_version(item['version'])
-        review.distro = item['distro']
-        review.rating = item['rating']
-
-        # check if valid
-        user_display_ignore = ['root',
-                               'Administrator',
-                               'Live System User',
-                               'user',
-                               'Unknown']
-        if item['user_display'] not in user_display_ignore:
-            review.user_display = item['user_display']
-
-        # log and add
-        db.eventlog.info(_get_client_address(),
-                         review.user_hash,
-                         review.app_id,
-                         "reviewed")
-        db.reviews.add(review, _get_client_address())
-    except CursorError as e:
-        return json_error(str(e))
-    return json_success()
-
-@app.route('/1.0/reviews/api/app/<app_id>/<user_hash>')
-@app.route('/1.0/reviews/api/app/<app_id>')
-def show_app(app_id, user_hash=None):
-    """
-    Return details about an application.
-    """
-    try:
-        db = get_db()
-        db.eventlog.info(_get_client_address(), user_hash, app_id, "getting")
-        reviews = db.reviews.get_for_app_id(app_id)
-    except CursorError as e:
-        return json_error(str(e))
-
-    # add key if user_hash specified
-    items_new = []
-    for review in reviews:
-        if review.reported > 0:
-            continue
-        item = review.__dict__
-        if user_hash:
-            item['user_skey'] = _get_user_key(user_hash, review.app_id)
-        items_new.append(item)
-
-    dat = json.dumps(items_new, sort_keys=True, indent=4, separators=(',', ': '))
-    return Response(response=dat,
-                    status=200, \
-                    mimetype="application/json")
-
-@app.route('/1.0/reviews/api/fetch', methods=['POST'])
-def fetch():
-    """
-    Return details about an application.
-    """
-    try:
-        item = json.loads(request.data.decode('utf8'))
-    except ValueError as e:
-        return json_error(str(e))
-    for key in ['app_id', 'user_hash', 'locale', 'distro', 'limit', 'version']:
-        if not key in item:
-            return json_error('invalid data, expected %s' % key)
-        if item[key] is None:
-            return json_error('missing data, expected %s' % key)
-        if item[key] == '':
-            return json_error('missing data, expected %s' % key)
-
-    # check format
-    if not len(item['user_hash']) == 40:
-        return json_error('the user_hash is invalid')
-
-    try:
-        db = get_db()
-        db.analytics_inc_fetch(item['app_id'])
-        reviews = db.reviews.get_for_app_id(item['app_id'])
-    except CursorError as e:
-        return json_error(str(e))
-
-    # also add any compat IDs
-    if 'compat_ids' in item:
-        for app_id in item['compat_ids']:
-            try:
-                reviews_tmp = db.reviews.get_for_app_id(app_id)
-            except CursorError as e:
-                return json_error(str(e))
-            reviews.extend(reviews_tmp)
-
-    # if user does not exist then create
-    user = db.users.get_by_hash(item['user_hash'])
-    if not user:
-        db.users.add(item['user_hash'])
-
-    # add score for review using secret sauce
-    items_new = []
-    for review in reviews:
-        if review.reported > 0:
-            continue
-
-        # the user isn't going to be able to read this
-        if not _locale_is_compatible(review.locale, item['locale']):
-            continue
-
-        # return all results
-        item_new = review.__dict__
-        item_new['user_skey'] = _get_user_key(item['user_hash'], review.app_id)
-        item_new['score'] = _get_review_score(review, item)
-
-        # the UI can hide the vote buttons on reviews already voted on
-        if db.reviews.vote_exists(review.review_id, item['user_hash']):
-            item_new['vote_id'] = 1
-
-        items_new.append(item_new)
-
-    # fake something so the user can get the user_skey
-    if len(items_new) == 0:
-        item_new = {}
-        item_new['score'] = 0
-        item_new['app_id'] = item['app_id']
-        item_new['user_hash'] = item['user_hash']
-        item_new['user_skey'] = _get_user_key(item['user_hash'], item['app_id'])
-        items_new.append(item_new)
-
-    # sort and cut to limit
-    items_new.sort(key=lambda item: item['score'], reverse=True)
-    if item['limit'] > 0:
-        items_new = items_new[:item['limit']]
-
-    dat = json.dumps(items_new, sort_keys=True, indent=4, separators=(',', ': '))
-    return Response(response=dat,
-                    status=200, \
-                    mimetype="application/json")
-
-@app.route('/1.0/reviews/api/all/<user_hash>')
-@app.route('/1.0/reviews/api/all')
-def all(user_hash=None):
-    """
-    Return all the reviews on the server as a JSON object.
-    """
-    try:
-        db = get_db()
-        db.eventlog.info(_get_client_address(), user_hash, None, "getting all reviews")
-        reviews = db.reviews.get_all()
-    except CursorError as e:
-        return json_error(str(e))
-
-    # the user specified a user_hash
-    if user_hash:
-        for review in reviews:
-            if review.reported > 0:
-                continue
-            item = review.__dict__
-            item['user_skey'] = _get_user_key(user_hash, review.app_id)
-
-    dat = json.dumps(reviews, sort_keys=True, indent=4, separators=(',', ': '))
-    return Response(response=dat,
-                    status=200, \
-                    mimetype="application/json")
-
-@app.route('/1.0/reviews/api/moderate/<user_hash>')
-@app.route('/1.0/reviews/api/moderate/<user_hash>/<locale>')
-def moderate(user_hash, locale=None):
-    """
-    Return all the reviews on the server the user can moderate.
-    """
-    try:
-        db = get_db()
-        db.eventlog.info(_get_client_address(), user_hash, None, "getting moderatable reviews")
-        reviews = db.reviews.get_all()
-    except CursorError as e:
-        return json_error(str(e))
-
-    # only return reviews the user has not already voted on
-    items_new = []
-    for review in reviews:
-        if locale and not _locale_is_compatible(review.locale, locale):
-            continue
-        if not db.reviews.vote_exists(review.review_id, user_hash):
-            item = review.__dict__
-            item['user_skey'] = _get_user_key(user_hash, review.app_id)
-            items_new.append(item)
-        if len(items_new) > 250:
-            break
-
-    dat = json.dumps(items_new, sort_keys=True, indent=4, separators=(',', ': '))
-    return Response(response=dat,
-                    status=200, \
-                    mimetype="application/json")
-
-def vote(val):
-    """
-    Up or downvote an existing review by @val karma points.
-    """
-    try:
-        item = json.loads(request.data.decode('utf8'))
-    except ValueError as e:
-        return json_error(str(e))
-    for key in ['review_id', 'app_id', 'user_hash', 'user_skey']:
-        if not key in item:
-            return json_error('invalid data, required %s' % key)
-        if item[key] is None:
-            return json_error('missing data, expected %s' % key)
-
-    # check format
-    if not len(item['user_hash']) == 40:
-        return json_error('the user_hash is invalid')
-    if not len(item['user_skey']) == 40:
-        return json_error('the user_skey is invalid')
-
-    if item['user_skey'] != _get_user_key(item['user_hash'], item['app_id']):
-        db = get_db()
-        db.eventlog.warn(_get_client_address(), item['user_hash'], None,
-                         "invalid user_skey of %s" % item['user_skey'])
-        #print("expected user_skey of %s" % _get_user_key(item['user_hash'], item['app_id']))
-        return json_error('invalid user_skey')
-    try:
-
-        # the user already has a review
-        db = get_db()
-        if db.reviews.vote_exists(item['review_id'], item['user_hash']):
-            db.eventlog.warn(_get_client_address(), item['user_hash'], item['app_id'],
-                             "duplicate vote")
-            return json_error('already voted on this app')
-
-        # update the per-user karma
-        user = db.users.get_by_hash(item['user_hash'])
-        if not user:
-            db.users.add(item['user_hash'])
-        else:
-
-            # user is naughty
-            if user.is_banned:
-                return json_error('account has been disabled due to abuse')
-
-            # the user is too harsh
-            if val < 0 and user.karma < -50:
-                return json_error('all negative karma used up')
-        db.users.update_karma(item['user_hash'], val)
-
-        # add the vote to the database
-        db.reviews.vote_add(item['review_id'], val, item['user_hash'])
-        db.eventlog.info(_get_client_address(), item['user_hash'], item['app_id'],
-                         "voted %i on review" % val)
-
-    except CursorError as e:
-        return json_error(str(e))
-    return json_success('voted #%i %i' % (item['review_id'], val))
-
-@app.route('/1.0/reviews/api/upvote', methods=['POST'])
-def upvote():
-    """
-    Upvote an existing review by one karma point.
-    """
-    return vote(1)
-
-@app.route('/1.0/reviews/api/downvote', methods=['POST'])
-def downvote():
-    """
-    Downvote an existing review by one karma point.
-    """
-    return vote(-1)
-
-@app.route('/1.0/reviews/api/dismiss', methods=['POST'])
-def dismiss():
-    """
-    Dismiss a review without rating it up or down.
-    """
-    return vote(0)
-
-@app.route('/1.0/reviews/api/report', methods=['POST'])
-def report():
-    """
-    Report a review for abuse.
-    """
-    return vote(-5)
-
-@app.route('/1.0/reviews/api/remove', methods=['POST'])
-def remove():
-    """
-    Remove a review.
-    """
-    try:
-        item = json.loads(request.data.decode('utf8'))
-    except ValueError as e:
-        return json_error(str(e))
-    for key in ['review_id', 'app_id', 'user_hash', 'user_skey']:
-        if not key in item:
-            return json_error('invalid data, required %s' % key)
-        if item[key] is None:
-            return json_error('missing data, expected %s' % key)
-
-    # check format
-    if not len(item['user_hash']) == 40:
-        return json_error('the user_hash is invalid')
-    if not len(item['user_skey']) == 40:
-        return json_error('the user_skey is invalid')
-
-    if item['user_skey'] != _get_user_key(item['user_hash'], item['app_id']):
-        db = get_db()
-        db.eventlog.warn(_get_client_address(), item['user_hash'], None,
-                         "invalid user_skey of %s" % item['user_skey'])
-        return json_error('invalid user_skey')
-    try:
-        # the user already has a review
-        db = get_db()
-        db.reviews.remove(item['review_id'], item['user_hash'])
-        db.eventlog.info(_get_client_address(),
-                         item['user_hash'],
-                         item['app_id'],
-                         "removed review")
-    except CursorError as e:
-        return json_error(str(e))
-    return json_success('removed review #%i' % item['review_id'])
-
-@app.route('/1.0/reviews/api/ratings/<app_id>')
-def rating_for_id(app_id):
-    """
-    Get the star ratings for a specific application.
-    """
-    try:
-        db = get_db()
-        ratings = db.reviews.get_rating_for_app_id(app_id)
-    except CursorError as e:
-        return json_error(str(e))
-
-    dat = json.dumps(ratings, sort_keys=True, indent=4, separators=(',', ': '))
-    return Response(response=dat,
-                    status=200, \
-                    mimetype="application/json")
-
-@app.route('/1.0/reviews/api/ratings')
-def ratings():
-    """
-    Get the star ratings for a specific application.
-    """
-    item = {}
-    try:
-        db = get_db()
-        app_ids = db.reviews.get_all_apps()
-        for app_id in app_ids:
-            ratings = db.reviews.get_rating_for_app_id(app_id, 2)
-            if len(ratings) == 0:
-                continue
-            item[app_id] = ratings
-    except CursorError as e:
-        return json_error(str(e))
-
-    dat = json.dumps(item, sort_keys=True, indent=4, separators=(',', ': '))
-    return Response(response=dat,
-                    status=200, \
-                    mimetype="application/json")
-
-if __name__ == '__main__':
-    print(">>>%s<<<" % _sanitised_version("16.12.3"))
-    print(">>>%s<<<" % _sanitised_version("4:16.12.3+p16.04+git20170325.0519-0"))
-    print(">>>%s<<<" % _sanitised_version("16.11.0~ds0"))
-    print(">>>%s<<<" % _sanitised_summary("   not sure why people include.   "))
-    print(">>>%s<<<" % _sanitised_description("   this is awesome :) !!!   "))
+    return send_from_directory("%s/odrs/static/" % os.environ['HOME'], os.path.basename(resource))
diff --git a/app_data/odrs/views_admin.py b/app_data/odrs/views_admin.py
index 032f0ac..a357ff7 100644
--- a/app_data/odrs/views_admin.py
+++ b/app_data/odrs/views_admin.py
@@ -1,9 +1,9 @@
 #!/usr/bin/python3
 # -*- coding: utf-8 -*-
 #
-# pylint: disable=invalid-name,missing-docstring
+# pylint: disable=invalid-name,missing-docstring,chained-comparison
 #
-# Copyright (C) 2015-2017 Richard Hughes <richard hughsie com>
+# Copyright (C) 2015-2019 Richard Hughes <richard hughsie com>
 #
 # SPDX-License-Identifier: GPL-3.0+
 
@@ -11,12 +11,15 @@ import datetime
 import calendar
 from math import ceil
 
+from sqlalchemy import text
+
 from flask import abort, request, flash, render_template, redirect, url_for
 from flask_login import login_required, current_user
 
-from odrs import app, get_db
-from .db import CursorError
-from .util import json_error
+from odrs import app, db
+from .models import Review, User, Moderator, Vote
+from .models import _vote_exists
+from .util import _get_datestr_from_dt, _error_permission_denied, _error_internal
 
 def _get_chart_labels_months():
     """ Gets the chart labels """
@@ -35,7 +38,7 @@ def _get_chart_labels_days():
     labels = []
     for i in range(0, 30):
         then = now - datetime.timedelta(i)
-        labels.append("%02i-%02i-%02i" % (then.year, then.month, then.day))
+        labels.append('%02i-%02i-%02i' % (then.year, then.month, then.day))
     return labels
 
 def _get_langs_for_user(user):
@@ -107,28 +110,48 @@ class Pagination():
                 yield num
                 last = num
 
-@app.errorhandler(400)
-def _error_internal(msg=None, errcode=400):
-    """ Error handler: Internal """
-    flash("Internal error: %s" % msg)
-    return render_template('error.html'), errcode
+def _get_analytics_by_interval(size, interval):
+    """ Gets analytics data """
+    array = []
+    now = datetime.date.today()
 
-@app.errorhandler(401)
-def _error_permission_denied(msg=None):
-    """ Error handler: Permission Denied """
-    flash("Permission denied: %s" % msg)
-    return render_template('error.html'), 401
+    # yes, there's probably a way to do this in one query
+    for i in range(size):
+        start = _get_datestr_from_dt(now - datetime.timedelta((i * interval) + interval - 1))
+        end = _get_datestr_from_dt(now - datetime.timedelta((i * interval) - 1))
+        stmt = text('SELECT fetch_cnt FROM analytics WHERE datestr BETWEEN :start AND :end')
+        res = db.session.execute(stmt.bindparams(start=start, end=end)) # pylint: disable=no-member
+
+        # add all these up
+        tmp = 0
+        for r in res:
+            tmp = tmp + r[0]
+        array.append(tmp)
+    return array
+
+def _get_stats_by_interval(size, interval, msg):
+    """ Gets stats data """
+    cnt = []
+    now = datetime.date.today()
 
+    # yes, there's probably a way to do this in one query
+    for i in range(size):
+        start = now - datetime.timedelta((i * interval) + interval - 1)
+        end = now - datetime.timedelta((i * interval) - 1)
+        stmt = text('SELECT COUNT(*) FROM eventlog '
+                    'WHERE message = :msg AND date_created BETWEEN :start AND :end')
+        res = db.session.execute(stmt.bindparams(start=start, end=end, msg=msg)) # pylint: disable=no-member
+        cnt.append(res.fetchone()[0])
+    return cnt
 
 @app.route('/admin/graph_month')
 @login_required
-def graph_month():
+def admin_graph_month():
     """
     Show nice graph graphs.
     """
-    db = get_db()
-    data_fetch = db.get_analytics_by_interval(30, 1)
-    data_review = db.get_stats_by_interval(30, 1, 'reviewed')
+    data_fetch = _get_analytics_by_interval(30, 1)
+    data_review = _get_stats_by_interval(30, 1, 'reviewed')
     return render_template('graph-month.html',
                            labels=_get_chart_labels_days()[::-1],
                            data_requests=data_fetch[::-1],
@@ -136,13 +159,12 @@ def graph_month():
 
 @app.route('/admin/graph_year')
 @login_required
-def graph_year():
+def admin_graph_year():
     """
     Show nice graph graphs.
     """
-    db = get_db()
-    data_fetch = db.get_analytics_by_interval(12, 30)
-    data_review = db.get_stats_by_interval(12, 30, 'reviewed')
+    data_fetch = _get_analytics_by_interval(12, 30)
+    data_review = _get_stats_by_interval(12, 30, 'reviewed')
     return render_template('graph-year.html',
                            labels=_get_chart_labels_months()[::-1],
                            data_requests=data_fetch[::-1],
@@ -150,137 +172,135 @@ def graph_year():
 
 @app.route('/admin/stats')
 @login_required
-def show_stats():
+def admin_show_stats():
     """
     Return the statistics page as HTML.
     """
     # security check
-    if current_user.username != 'admin':
+    if not current_user.is_admin:
         return _error_permission_denied('Unable to show stats as non-admin')
-    try:
-        db = get_db()
-        stats = db.get_stats()
-    except CursorError as e:
-        return _error_internal(str(e))
 
-    # stats
-    results_stats = []
-    for item in sorted(stats):
-        results_stats.append((item, stats[item]))
+    stats = {}
+
+    # get the total number of reviews
+    rs = db.session.execute("SELECT COUNT(*) FROM reviews;") # pylint: disable=no-member
+    stats['Active reviews'] = rs.fetchone()[0]
+
+    # unique reviewers
+    rs = db.session.execute("SELECT COUNT(DISTINCT(user_hash)) FROM reviews;") # pylint: disable=no-member
+    stats['Unique reviewers'] = rs.fetchone()[0]
+
+    # total votes
+    rs = db.session.execute("SELECT COUNT(*) FROM votes WHERE val = 1;") # pylint: disable=no-member
+    stats['User upvotes'] = rs.fetchone()[0]
+    rs = db.session.execute("SELECT COUNT(*) FROM votes WHERE val = -1;") # pylint: disable=no-member
+    stats['User downvotes'] = rs.fetchone()[0]
+
+    # unique voters
+    rs = db.session.execute("SELECT COUNT(DISTINCT(user_hash)) FROM votes;") # pylint: disable=no-member
+    stats['Unique voters'] = rs.fetchone()[0]
+
+    # unique languages
+    rs = db.session.execute("SELECT COUNT(DISTINCT(locale)) FROM reviews;") # pylint: disable=no-member
+    stats['Unique languages'] = rs.fetchone()[0]
+
+    # unique distros
+    rs = db.session.execute("SELECT COUNT(DISTINCT(distro)) FROM reviews;") # pylint: disable=no-member
+    stats['Unique distros'] = rs.fetchone()[0]
+
+    # unique apps
+    rs = db.session.execute("SELECT COUNT(DISTINCT(app_id)) FROM reviews;") # pylint: disable=no-member
+    stats['Unique apps reviewed'] = rs.fetchone()[0]
+
+    # unique distros
+    rs = db.session.execute("SELECT COUNT(*) FROM reviews WHERE reported > 0;") # pylint: disable=no-member
+    stats['Reported reviews'] = rs.fetchone()[0]
+
+    # star reviews
+    for star in range(1, 6):
+        rs = db.session.execute("SELECT COUNT(*) FROM reviews WHERE rating = {};".format(star * 20)) # 
pylint: disable=no-member
+        stats['%i star reviews' % star] = rs.fetchone()[0]
 
     # popularity view
-    results_viewed = []
-    for review in db.get_analytics_fetch():
-        results_viewed.append((review[0], review[1]))
+    viewed = db.session.execute("SELECT DISTINCT app_id, SUM(fetch_cnt) AS total " # pylint: 
disable=no-member
+                                "FROM analytics WHERE app_id IS NOT NULL "
+                                "GROUP BY app_id ORDER BY total DESC LIMIT 50;")
 
     # popularity reviews
-    results_submitted = []
-    for review in db.get_stats_fetch('reviewed'):
-        results_submitted.append((review[0], review[1]))
+    submitted = db.session.execute("SELECT DISTINCT app_id, COUNT(app_id) as total " # pylint: 
disable=no-member
+                                   "FROM eventlog WHERE app_id IS NOT NULL "
+                                   "AND message='reviewed' GROUP BY app_id "
+                                   "ORDER BY total DESC LIMIT 50;")
     return render_template('stats.html',
-                           results_stats=results_stats,
-                           results_viewed=results_viewed,
-                           results_submitted=results_submitted)
+                           results_stats=stats,
+                           results_viewed=viewed,
+                           results_submitted=submitted)
 
 @app.route('/admin/distros')
 @login_required
-def distros():
+def admin_distros():
     """
     Return the statistics page as HTML.
     """
     # security check
-    if current_user.username != 'admin':
-        return _error_permission_denied('Unable to show distros as non-admin')
-    try:
-        db = get_db()
-        stats = db.get_stats_distro(8)
-    except CursorError as e:
-        return _error_internal(str(e))
+    if not current_user.is_admin:
+        return _error_permission_denied('Unable to show admin_distros as non-admin')
+    rs = db.session.execute("SELECT DISTINCT(distro), COUNT(distro) AS total " # pylint: disable=no-member
+                            "FROM reviews GROUP BY distro ORDER BY total DESC "
+                            "LIMIT 8;")
     labels = []
     data = []
-    for s in stats:
+    for s in rs:
         name = s[0]
         for suffix in [' Linux', ' GNU/Linux', ' OS', ' Linux']:
             if name.endswith(suffix):
                 name = name[:-len(suffix)]
-        labels.append(str(name))
+        labels.append(name)
         data.append(s[1])
     return render_template('distros.html', labels=labels, data=data)
 
-@app.context_processor
-def utility_processor():
-    def format_rating(rating):
-        nr_stars = int(rating / 20)
-        tmp = ''
-        for _ in range(0, nr_stars):
-            tmp += '★'
-        for _ in range(0, 5 - nr_stars):
-            tmp += '☆'
-        return tmp
-
-    def format_truncate(tmp, length):
-        if len(tmp) <= length:
-            return tmp
-        return tmp[:length] + '…'
-
-    def format_timestamp(tmp):
-        if not tmp:
-            return 'n/a'
-        return datetime.datetime.fromtimestamp(tmp).strftime('%Y-%m-%d %H:%M:%S')
-
-    def url_for_other_page(page):
-        args = request.view_args.copy()
-        args['page'] = page
-        return url_for(request.endpoint, **args)
-
-    return dict(format_rating=format_rating,
-                format_truncate=format_truncate,
-                format_timestamp=format_timestamp,
-                url_for_other_page=url_for_other_page)
-
 @app.route('/admin/review/<review_id>')
+@login_required
 def admin_show_review(review_id):
     """
     Show a specific review as HTML.
     """
-    try:
-        db = get_db()
-        review = db.reviews.get_for_id(review_id)
-    except CursorError as e:
-        return _error_internal(str(e))
+    review = db.session.query(Review).filter(Review.review_id == review_id).first()
     if not review:
         return _error_internal('no review with that ID')
 
     # has the user already voted
     user_hash = _get_hash_for_user(current_user)
     if user_hash:
-        vote_exists = db.reviews.vote_exists(review_id, user_hash)
+        vote = _vote_exists(review_id, user_hash)
     else:
-        vote_exists = False
+        vote = None
 
-    return render_template('show.html', r=review, vote_exists=vote_exists)
+    return render_template('show.html', r=review, vote_exists=vote)
 
 @app.route('/admin/modify/<review_id>', methods=['POST'])
 @login_required
 def admin_modify(review_id):
     """ Change details about a review """
-    try:
-        db = get_db()
-        review = db.reviews.get_for_id(review_id)
-    except CursorError as e:
-        return _error_internal(str(e))
+    review = db.session.query(Review).filter(Review.review_id == review_id).first()
     if not review:
         return _error_internal('no review with that ID')
-    review.distro = request.form['distro']
-    review.locale = request.form['locale']
-    if len(request.form['user_display']) == 0:
-        review.user_display = None
-    else:
-        review.user_display = request.form['user_display']
-    review.description = request.form['description']
-    review.summary = request.form['summary']
-    review.version = request.form['version']
-    db.reviews.modify(review)
+    if 'distro' in request.form:
+        review.distro = request.form['distro']
+    if 'locale' in request.form:
+        review.locale = request.form['locale']
+    if 'user_display' in request.form:
+        if len(request.form['user_display']) == 0:
+            review.user_display = None
+        else:
+            review.user_display = request.form['user_display']
+    if 'description' in request.form:
+        review.description = request.form['description']
+    if 'summary' in request.form:
+        review.summary = request.form['summary']
+    if 'version' in request.form:
+        review.version = request.form['version']
+    db.session.commit()
     return redirect(url_for('.admin_show_review', review_id=review_id))
 
 @app.route('/admin/user_ban/<user_hash>')
@@ -288,54 +308,46 @@ def admin_modify(review_id):
 def admin_user_ban(user_hash):
     """ Change details about a review """
     # security check
-    if current_user.username != 'admin':
+    if not current_user.is_admin:
         return _error_permission_denied('Unable to ban user as non-admin')
-    try:
-        db = get_db()
-        db.users.ban(user_hash)
-    except CursorError as e:
-        return _error_internal(str(e))
-    return redirect(url_for('.admin_show_reported'))
+    print(db.session.query(User).all())
+    user = db.session.query(User).filter(User.user_hash == user_hash).first()
+    if not user:
+        return _error_internal('no user with that user_hash')
+    user.is_banned = True
+    db.session.commit()
+    flash('Banned user')
+    return redirect(url_for('.odrs_show_reported'))
 
 @app.route('/admin/unreport/<review_id>')
 @login_required
 def admin_unreport(review_id):
     """ Unreport a perfectly valid review """
-    try:
-        db = get_db()
-        review = db.reviews.get_for_id(review_id)
-    except CursorError as e:
-        return _error_internal(str(e))
+    review = db.session.query(Review).filter(Review.review_id == review_id).first()
     if not review:
         return _error_internal('no review with that ID')
     review.reported = 0
-    db.reviews.modify(review)
+    db.session.commit()
+    flash('Review unreported')
     return redirect(url_for('.admin_show_review', review_id=review_id))
 
 @app.route('/admin/unremove/<review_id>')
 @login_required
 def admin_unremove(review_id):
     """ Unreport a perfectly valid review """
-    try:
-        db = get_db()
-        review = db.reviews.get_for_id(review_id)
-    except CursorError as e:
-        return _error_internal(str(e))
+    review = db.session.query(Review).filter(Review.review_id == review_id).first()
     if not review:
         return _error_internal('no review with that ID')
     review.date_deleted = 0
-    db.reviews.modify(review)
+    db.session.commit()
+    flash('Review unremoved')
     return redirect(url_for('.admin_show_review', review_id=review_id))
 
 @app.route('/admin/englishify/<review_id>')
 @login_required
 def admin_englishify(review_id):
     """ Marks a review as writen in English """
-    try:
-        db = get_db()
-        review = db.reviews.get_for_id(review_id)
-    except CursorError as e:
-        return _error_internal(str(e))
+    review = db.session.query(Review).filter(Review.review_id == review_id).first()
     if not review:
         return _error_internal('no review with that ID')
     parts = review.locale.split('_')
@@ -343,36 +355,30 @@ def admin_englishify(review_id):
         review.locale = 'en'
     else:
         review.locale = 'en_' + parts[1]
-    db.reviews.modify(review)
+    db.session.commit()
     return redirect(url_for('.admin_show_review', review_id=review_id))
 
 @app.route('/admin/anonify/<review_id>')
 @login_required
 def admin_anonify(review_id):
     """ Removes the username from the review """
-    try:
-        db = get_db()
-        review = db.reviews.get_for_id(review_id)
-    except CursorError as e:
-        return _error_internal(str(e))
+    review = db.session.query(Review).filter(Review.review_id == review_id).first()
     if not review:
         return _error_internal('no review with that ID')
     review.user_display = None
-    db.reviews.modify(review)
+    db.session.commit()
     return redirect(url_for('.admin_show_review', review_id=review_id))
 
 @app.route('/admin/delete/<review_id>/force')
 @login_required
 def admin_delete_force(review_id):
     """ Delete a review """
-    try:
-        db = get_db()
-        review = db.reviews.get_for_id(review_id)
-    except CursorError as e:
-        return _error_internal(str(e))
+    review = db.session.query(Review).filter(Review.review_id == review_id).first()
     if not review:
         return _error_internal('no review with that ID')
-    db.reviews.delete(review)
+    db.session.delete(review)
+    db.session.commit()
+    flash('Deleted review')
     return redirect(url_for('.admin_show_all'))
 
 @app.route('/admin/delete/<review_id>')
@@ -381,24 +387,20 @@ def admin_delete(review_id):
     """ Ask for confirmation to delete a review """
     return render_template('delete.html', review_id=review_id)
 
-@app.route('/admin/show/all', defaults={'page': 1})
+@app.route('/admin/show/all')
 @app.route('/admin/show/all/page/<int:page>')
-def admin_show_all(page):
+@login_required
+def admin_show_all(page=1):
     """
     Return all the reviews on the server as HTML.
     """
 
-    try:
-        db = get_db()
-        reviews = db.reviews.get_all()
-    except CursorError as e:
-        return _error_internal(str(e))
+    reviews = db.session.query(Review).order_by(Review.date_created.desc()).all()
     if not reviews and page != 1:
         abort(404)
 
     reviews_per_page = 20
     pagination = Pagination(page, reviews_per_page, len(reviews))
-    # FIXME: do this database side...
     reviews = reviews[(page-1) * reviews_per_page:page * reviews_per_page]
     return render_template('show-all.html',
                            pagination=pagination,
@@ -406,15 +408,10 @@ def admin_show_all(page):
 
 @app.route('/admin/show/unmoderated')
 @login_required
-def admin_show_unmoderated():
+def odrs_show_unmoderated():
     """
     Return all the reviews on the server as HTML.
     """
-    try:
-        db = get_db()
-        reviews_all = db.reviews.get_all()
-    except Exception as e:
-        return _error_internal(str(e))
     user_hash = _get_hash_for_user(current_user)
     if not user_hash:
         return _error_internal('no user_hash...')
@@ -422,11 +419,11 @@ def admin_show_unmoderated():
     # filter by the languages the moderator understands
     reviews = []
     langs = _get_langs_for_user(current_user)
-    for r in reviews_all:
+    for r in db.session.query(Review).all():
         lang = r.locale.split('_')[0]
         if langs and lang not in langs:
             continue
-        if db.reviews.vote_exists(r.review_id, user_hash):
+        if _vote_exists(r.review_id, user_hash):
             continue
         if len(reviews) > 20:
             break
@@ -437,68 +434,41 @@ def admin_show_unmoderated():
 
 @app.route('/admin/show/reported')
 @login_required
-def admin_show_reported():
+def odrs_show_reported():
     """
     Return all the reported reviews on the server as HTML.
     """
-    reviews_filtered = []
-    try:
-        db = get_db()
-        reviews = db.reviews.get_all()
-        for review in reviews:
-            if review.reported > 0:
-                reviews_filtered.append(review)
-    except CursorError as e:
-        return _error_internal(str(e))
-    return render_template('show-all.html', reviews=reviews_filtered)
+    reviews = db.session.query(Review).\
+                    filter(Review.reported > 0).\
+                    order_by(Review.date_created.desc()).all()
+    return render_template('show-all.html', reviews=reviews)
 
 @app.route('/admin/show/user/<user_hash>')
+@login_required
 def admin_show_user(user_hash):
     """
     Return all the reviews from a user on the server as HTML.
     """
-    reviews_filtered = []
-    try:
-        db = get_db()
-        reviews = db.reviews.get_all()
-        for review in reviews:
-            if review.user_hash == user_hash:
-                reviews_filtered.append(review)
-    except CursorError as e:
-        return _error_internal(str(e))
-    return render_template('show-all.html', reviews=reviews_filtered)
+    reviews = db.session.query(Review).filter(Review.user_hash == user_hash).all()
+    return render_template('show-all.html', reviews=reviews)
 
 @app.route('/admin/show/app/<app_id>')
+@login_required
 def admin_show_app(app_id):
     """
     Return all the reviews from a user on the server as HTML.
     """
-    reviews_filtered = []
-    try:
-        db = get_db()
-        reviews = db.reviews.get_all()
-        for review in reviews:
-            if review.app_id == app_id:
-                reviews_filtered.append(review)
-    except CursorError as e:
-        return _error_internal(str(e))
-    return render_template('show-all.html', reviews=reviews_filtered)
+    reviews = db.session.query(Review).filter(Review.app_id == app_id).all()
+    return render_template('show-all.html', reviews=reviews)
 
 @app.route('/admin/show/lang/<locale>')
+@login_required
 def admin_show_lang(locale):
     """
     Return all the reviews from a user on the server as HTML.
     """
-    reviews_filtered = []
-    try:
-        db = get_db()
-        reviews = db.reviews.get_all()
-        for review in reviews:
-            if review.locale == locale:
-                reviews_filtered.append(review)
-    except CursorError as e:
-        return _error_internal(str(e))
-    return render_template('show-all.html', reviews=reviews_filtered)
+    reviews = db.session.query(Review).filter(Review.locale == locale).all()
+    return render_template('show-all.html', reviews=reviews)
 
 @app.route('/admin/users/all')
 @login_required
@@ -506,13 +476,17 @@ def admin_users_all():
     """
     Return all the users as HTML.
     """
-    try:
-        db = get_db()
-        users_awesome = db.users.get_by_karma(best=True)
-        users_haters = db.users.get_by_karma(best=False)
-    except CursorError as e:
-        return _error_internal(str(e))
-    return render_template('users.html', users_awesome=users_awesome, users_haters=users_haters)
+    users_awesome = db.session.query(User).\
+                        filter(User.karma != 0).\
+                        order_by(User.karma.desc()).\
+                        limit(10).all()
+    users_haters = db.session.query(User).\
+                        filter(User.karma != 0).\
+                        order_by(User.karma.asc()).\
+                        limit(10).all()
+    return render_template('users.html',
+                           users_awesome=users_awesome,
+                           users_haters=users_haters)
 
 @app.route('/admin/moderators/all')
 @login_required
@@ -521,13 +495,9 @@ def admin_moderator_show_all():
     Return all the moderators as HTML.
     """
     # security check
-    if current_user.username != 'admin':
+    if not current_user.is_admin:
         return _error_permission_denied('Unable to show all moderators')
-    try:
-        db = get_db()
-        mods = db.moderators.get_all()
-    except CursorError as e:
-        return _error_internal(str(e))
+    mods = db.session.query(Moderator).all()
     return render_template('mods.html', mods=mods)
 
 @app.route('/admin/moderator/add', methods=['GET', 'POST'])
@@ -540,7 +510,7 @@ def admin_moderator_add():
         return redirect(url_for('.profile'))
 
     # security check
-    if current_user.username != 'admin':
+    if not current_user.is_admin:
         return _error_permission_denied('Unable to add moderator as non-admin')
 
     if not 'password_new' in request.form:
@@ -551,82 +521,70 @@ def admin_moderator_add():
         return _error_permission_denied('Unable to add user as no display name')
     if not 'email' in request.form:
         return _error_permission_denied('Unable to add user as no email')
-    try:
-        db = get_db()
-        auth = db.moderators.get_by_username(request.form['username_new'])
-    except CursorError as e:
-        return _error_internal(str(e))
-    if auth:
-        return _error_internal('Already a entry with that username', 422)
+    if db.session.query(Moderator).\
+            filter(Moderator.username == request.form['username_new']).first():
+        flash('Already a entry with that username', 'warning')
+        return redirect(url_for('.admin_moderator_show_all'))
 
     # verify password
     password = request.form['password_new']
     if not _password_check(password):
-        return redirect(url_for('.admin_moderator_show_all'), 422)
+        return redirect(url_for('.admin_moderator_show_all'))
 
     # verify email
     email = request.form['email']
     if not _email_check(email):
         flash('Invalid email address', 'warning')
-        return redirect(url_for('.admin_moderator_show_all'), 422)
+        return redirect(url_for('.admin_moderator_show_all'))
 
     # verify name
     display_name = request.form['display_name']
     if len(display_name) < 3:
         flash('Name invalid', 'warning')
-        return redirect(url_for('.admin_moderator_show_all'), 422)
+        return redirect(url_for('.admin_moderator_show_all'))
 
     # verify username
     username_new = request.form['username_new']
     if len(username_new) < 3:
         flash('Username invalid', 'warning')
-        return redirect(url_for('.admin_moderator_show_all'), 422)
-    try:
-        db.moderators.add(username_new, password, display_name, email)
-    except CursorError as e:
-        return _error_internal(str(e))
+        return redirect(url_for('.admin_moderator_show_all'))
+    db.session.add(Moderator(username_new, password, display_name, email))
+    db.session.commit()
     flash('Added user')
-    return redirect(url_for('.admin_moderator_show_all'), 302)
+    return redirect(url_for('.admin_moderator_show_all'))
 
-@app.route('/admin/moderator/<username>/admin')
+@app.route('/admin/moderator/<moderator_id>/admin')
 @login_required
-def admin_moderator_show(username):
+def odrs_moderator_show(moderator_id):
     """
     Shows an admin panel for a moderator
     """
-    if username != current_user.username and current_user.username != 'admin':
+    if moderator_id != current_user.moderator_id and not current_user.is_admin:
         return _error_permission_denied('Unable to show moderator information')
-    try:
-        db = get_db()
-        mod = db.moderators.get_by_username(username)
-    except CursorError as e:
-        return _error_internal(str(e))
+    mod = db.session.query(Moderator).filter(Moderator.moderator_id == moderator_id).first()
+    if not mod:
+        flash("No entry with moderator ID {}".format(moderator_id), 'warning')
+        return redirect(url_for('.admin_moderator_show_all'))
     return render_template('modadmin.html', u=mod)
 
-@app.route('/admin/moderator/<username>/delete')
+@app.route('/admin/moderator/<moderator_id>/delete')
 @login_required
-def admin_moderate_delete(username):
+def admin_moderate_delete(moderator_id):
     """ Delete a moderator """
 
     # security check
-    if current_user.username != 'admin':
+    if not current_user.is_admin:
         return _error_permission_denied('Unable to delete moderator as not admin')
 
     # check whether exists in database
-    try:
-        db = get_db()
-        mod = db.moderators.get_by_username(username)
-    except CursorError as e:
-        return _error_internal(str(e))
+    mod = db.session.query(Moderator).filter(Moderator.moderator_id == moderator_id).first()
     if not mod:
-        flash("No entry with username %s" % username, 'error')
-        return redirect(url_for('.admin_moderator_show_all'), 422)
-    try:
-        db.moderators.remove(username)
-    except CursorError as e:
-        return _error_internal(str(e))
+        flash("No entry with moderator ID {}".format(moderator_id), 'warning')
+        return redirect(url_for('.admin_moderator_show_all'))
+    db.session.delete(mod)
+    db.session.commit()
     flash('Deleted user')
-    return redirect(url_for('.admin_moderator_show_all'), 302)
+    return redirect(url_for('.admin_moderator_show_all'))
 
 @app.route('/admin/vote/<review_id>/<val_str>')
 @login_required
@@ -643,50 +601,52 @@ def admin_vote(review_id, val_str):
         val = 0
     else:
         return _error_internal('invalid vote value...')
-    try:
-        # the user already has a review
-        db = get_db()
-        if db.reviews.vote_exists(review_id, user_hash):
-            flash('already voted on this app')
-            return redirect(url_for('.admin_show_review', review_id=review_id))
-        user = db.users.get_by_hash(user_hash)
-        if not user:
-            db.users.add(user_hash)
-        db.users.update_karma(user_hash, val)
-        db.reviews.vote_add(review_id, val, user_hash)
-    except CursorError as e:
-        return json_error(str(e))
-    return redirect(url_for('.admin_show_review', review_id=review_id))
 
+    # the user already has a review
+    if _vote_exists(review_id, user_hash):
+        flash('already voted on this app')
+        return redirect(url_for('.admin_show_review', review_id=review_id))
+
+    user = db.session.query(User).filter(User.user_hash == user_hash).first()
+    if not user:
+        user = User(user_hash)
+        db.session.add(user)
+    user.karma += val
+    db.session.add(Vote(user_hash, val, review_id=review_id))
+    db.session.commit()
+    flash('Recorded vote')
+    return redirect(url_for('.admin_show_review', review_id=review_id))
 
-@app.route('/admin/moderator/<username>/modify_by_admin', methods=['POST'])
+@app.route('/admin/moderator/<moderator_id>/modify_by_admin', methods=['POST'])
 @login_required
-def user_modify_by_admin(username):
+def admin_user_modify_by_admin(moderator_id):
     """ Change details about the any user """
 
     # security check
-    if username != current_user.username and current_user.username != 'admin':
+    if moderator_id != current_user.moderator_id and not current_user.is_admin:
         return _error_permission_denied('Unable to modify user as non-admin')
 
+    mod = db.session.query(Moderator).filter(Moderator.moderator_id == moderator_id).first()
+    if not mod:
+        flash('moderator_id invalid', 'warning')
+        return redirect(url_for('.admin_moderator_show_all'))
+
     # set each thing in turn
+    mod.is_enabled = 'is_enabled' in request.form
     for key in ['display_name',
                 'email',
                 'password',
                 'user_hash',
-                'locales',
-                'is_enabled']:
+                'locales']:
         # unchecked checkbuttons are not included in the form data
-        if key in request.form:
-            tmp = request.form[key]
-        else:
-            tmp = '0'
-        try:
-            # don't set the optional password
-            if key == 'password' and len(tmp) == 0:
-                continue
-            db = get_db()
-            db.moderators.set_property(username, key, tmp)
-        except CursorError as e:
-            return _error_internal(str(e))
+        if key not in request.form:
+            continue
+
+        val = request.form[key]
+        # don't set the optional password
+        if key == 'password' and len(val) == 0:
+            continue
+        setattr(mod, key, val)
+    db.session.commit()
     flash('Updated profile')
-    return redirect(url_for('.admin_moderator_show', username=username))
+    return redirect(url_for('.odrs_moderator_show', moderator_id=moderator_id))
diff --git a/app_data/odrs/views_api.py b/app_data/odrs/views_api.py
new file mode 100644
index 0000000..8ca5311
--- /dev/null
+++ b/app_data/odrs/views_api.py
@@ -0,0 +1,447 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+#
+# pylint: disable=invalid-name,missing-docstring
+#
+# Copyright (C) 2015-2019 Richard Hughes <richard hughsie com>
+#
+# SPDX-License-Identifier: GPL-3.0+
+
+import json
+import math
+import datetime
+
+from sqlalchemy.dialects.mysql import insert
+
+from flask import request, Response
+
+from odrs import app, db
+
+from .models import Review, User, Vote, Analytic
+from .models import _vote_exists
+from .util import json_success, json_error, _locale_is_compatible, _eventlog_add, _get_user_key, 
_get_datestr_from_dt
+from .util import _sanitised_version, _sanitised_summary, _sanitised_description, _get_rating_for_app_id
+
+def _get_client_address():
+    """ Gets user IP address """
+    if request.headers.getlist('X-Forwarded-For'):
+        return request.headers.getlist('X-Forwarded-For')[0]
+    return request.remote_addr
+
+def _get_review_score(review, item):
+    """ Gets a review score given certain parameters """
+    ku = review.karma_up
+    kd = review.karma_down
+
+    # hardcode some penalties
+    if review.version != item['version']:
+        kd = kd + 1
+    if review.distro != item['distro']:
+        kd = kd + 1
+
+    # algorithm from http://www.evanmiller.org/how-not-to-sort-by-average-rating.html
+    wilson = 0
+    if ku > 0 or kd > 0:
+        wilson = ((ku + 1.9208) / (ku + kd) -
+                  1.96 * math.sqrt((ku * kd) / (ku + kd) + 0.9604) /
+                  (ku + kd)) / (1 + 3.8416 / (ku + kd))
+        wilson *= 100
+    return int(wilson)
+
+def _check_str(val):
+    """ Return with success if the summary and description """
+    if val.find('<') != -1:
+        return False
+    if val.find('<') != -1:
+        return False
+    return True
+
+@app.route('/1.0/reviews/api/submit', methods=['POST'])
+def api_submit():
+    """
+    Submits a new review.
+    """
+    try:
+        item = json.loads(request.data.decode('utf8'))
+    except ValueError as e:
+        return json_error(str(e))
+    required_fields = ['app_id', 'locale', 'summary', 'description',
+                       'user_hash', 'version', 'distro', 'rating',
+                       'user_display']
+    for key in required_fields:
+        if not key in item:
+            return json_error('invalid data, expected %s' % key)
+        if not item[key]:
+            return json_error('missing data, expected %s' % key)
+
+    # check format
+    if not len(item['user_hash']) == 40:
+        return json_error('the user_hash is invalid')
+
+    # check fields for markup and length
+    if len(item['summary']) > 70:
+        return json_error('summary is too long')
+    if len(item['description']) > 3000:
+        return json_error('description is too long')
+    for key in ['summary', 'description']:
+        if not _check_str(item[key]):
+            return json_error('%s is not a valid string' % key)
+
+    # check user has not been banned
+    user = db.session.query(User).filter(User.user_hash == item['user_hash']).first()
+    if user:
+        if user.is_banned:
+            return json_error('account has been disabled due to abuse')
+    else:
+        user = User(item['user_hash'])
+        db.session.add(user)
+
+    # user has already reviewed
+    if db.session.query(Review).\
+            filter(Review.app_id == item['app_id']).\
+            filter(Review.user_hash == item['user_hash']).first():
+        _eventlog_add(_get_client_address(),
+                      item['user_hash'],
+                      item['app_id'],
+                      'already reviewed')
+        return json_error('already reviewed this app')
+
+    # create new
+    review = Review()
+    review.app_id = item['app_id']
+    review.locale = item['locale']
+    review.summary = _sanitised_summary(item['summary'])
+    review.description = _sanitised_description(item['description'])
+    review.user_hash = item['user_hash']
+    review.version = _sanitised_version(item['version'])
+    review.distro = item['distro']
+    review.rating = item['rating']
+    review.user_addr = _get_client_address()
+
+    # check if valid
+    user_display_ignore = ['root',
+                           'Administrator',
+                           'Live System User',
+                           'user',
+                           'Unknown']
+    if item['user_display'] not in user_display_ignore:
+        review.user_display = item['user_display']
+
+    # log and add
+    _eventlog_add(_get_client_address(),
+                  review.user_hash,
+                  review.app_id,
+                  'reviewed')
+    db.session.add(review)
+    db.session.commit()
+    return json_success()
+
+def _get_for_app_id(app_id, user_hash=None):
+    reviews = db.session.query(Review).\
+                    filter(Review.app_id == app_id).\
+                    filter(Review.reported == 0).\
+                    order_by(Review.date_created.desc()).all()
+    return [review.asdict(user_hash) for review in reviews]
+
+@app.route('/1.0/reviews/api/app/<app_id>/<user_hash>')
+@app.route('/1.0/reviews/api/app/<app_id>')
+def api_show_app(app_id, user_hash=None):
+    """
+    Return details about an application.
+    """
+    reviews = _get_for_app_id(app_id, user_hash)
+    dat = json.dumps(reviews, sort_keys=True, indent=4, separators=(',', ': '))
+    return Response(response=dat,
+                    status=200, \
+                    mimetype='application/json')
+
+@app.route('/1.0/reviews/api/fetch', methods=['POST'])
+def api_fetch():
+    """
+    Return details about an application.
+    """
+    try:
+        item = json.loads(request.data.decode('utf8'))
+    except ValueError as e:
+        return json_error(str(e))
+    for key in ['app_id', 'user_hash', 'locale', 'distro', 'limit', 'version']:
+        if not key in item:
+            return json_error('invalid data, expected %s' % key)
+        if not item[key]:
+            return json_error('missing data, expected %s' % key)
+
+    # check format
+    if not len(item['user_hash']) == 40:
+        return json_error('the user_hash is invalid')
+
+    # increments the fetch count on one specific application
+    datestr = _get_datestr_from_dt(datetime.date.today())
+    stmt = insert(Analytic).values(datestr=datestr, app_id=item['app_id'])
+    if db.session.bind.dialect.name != 'sqlite': # pylint: disable=no-member
+        stmt_ondupe = stmt.on_duplicate_key_update(datestr=stmt.inserted.datestr,
+                                                   app_id=stmt.inserted.app_id,
+                                                   fetch_cnt=stmt.inserted.fetch_cnt + 1)
+    else:
+        stmt_ondupe = stmt
+    db.session.execute(stmt_ondupe) # pylint: disable=no-member
+
+    # also add any compat IDs
+    app_ids = [item['app_id']]
+    if 'compat_ids' in item:
+        app_ids.extend(item['compat_ids'])
+    reviews = db.session.query(Review).\
+                    filter(Review.app_id.in_(app_ids)).\
+                    filter(Review.reported == 0).all()
+
+    # if user does not exist then create
+    user = db.session.query(User).filter(User.user_hash == item['user_hash']).first()
+    if not user:
+        user = User(user_hash=item['user_hash'])
+        db.session.add(user)
+        db.session.commit()
+
+    # add score for review using secret sauce
+    items_new = []
+    for review in reviews:
+
+        # the user isn't going to be able to read this
+        if not _locale_is_compatible(review.locale, item['locale']):
+            continue
+
+        # return all results
+        item_new = review.asdict(item['user_hash'])
+        item_new['score'] = _get_review_score(review, item)
+        item_new['user_skey'] = _get_user_key(item['user_hash'], item['app_id'])
+
+        # the UI can hide the vote buttons on reviews already voted on
+        if _vote_exists(review.review_id, item['user_hash']):
+            item_new['vote_id'] = 1
+
+        items_new.append(item_new)
+
+    # fake something so the user can get the user_skey
+    if len(items_new) == 0:
+        item_new = {}
+        item_new['score'] = 0
+        item_new['app_id'] = item['app_id']
+        item_new['user_hash'] = item['user_hash']
+        item_new['user_skey'] = _get_user_key(item['user_hash'], item['app_id'])
+        items_new.append(item_new)
+
+    # sort and cut to limit
+    items_new.sort(key=lambda item: item['score'], reverse=True)
+    if item['limit'] > 0:
+        items_new = items_new[:item['limit']]
+
+    dat = json.dumps(items_new, sort_keys=True, indent=4, separators=(',', ': '))
+    return Response(response=dat,
+                    status=200, \
+                    mimetype='application/json')
+
+@app.route('/1.0/reviews/api/all/<user_hash>')
+@app.route('/1.0/reviews/api/all')
+def api_all(user_hash=None):
+    """
+    Return all the reviews on the server as a JSON object.
+    """
+    reviews = db.session.query(Review).filter(Review.reported == 0).all()
+    items = [review.asdict(user_hash) for review in reviews]
+    dat = json.dumps(items, sort_keys=True, indent=4, separators=(',', ': '))
+    return Response(response=dat,
+                    status=200, \
+                    mimetype='application/json')
+
+@app.route('/1.0/reviews/api/moderate/<user_hash>')
+@app.route('/1.0/reviews/api/moderate/<user_hash>/<locale>')
+def api_moderate(user_hash, locale=None):
+    """
+    Return all the reviews on the server the user can moderate.
+    """
+    # only return reviews the user has not already voted on
+    items = []
+    for review in db.session.query(Review).all():
+        if locale and not _locale_is_compatible(review.locale, locale):
+            continue
+        if _vote_exists(review.review_id, user_hash):
+            continue
+        items.append(review.asdict(user_hash))
+        if len(items) > 250:
+            break
+    dat = json.dumps(items, sort_keys=True, indent=4, separators=(',', ': '))
+    return Response(response=dat,
+                    status=200, \
+                    mimetype='application/json')
+
+def _vote(val):
+    """
+    Up or downvote an existing review by @val karma points.
+    """
+    try:
+        item = json.loads(request.data.decode('utf8'))
+    except ValueError as e:
+        return json_error(str(e))
+    for key in ['review_id', 'app_id', 'user_hash', 'user_skey']:
+        if not key in item:
+            return json_error('invalid data, required %s' % key)
+        if item[key] is None:
+            return json_error('missing data, expected %s' % key)
+
+    # check format
+    if not len(item['user_hash']) == 40:
+        return json_error('the user_hash is invalid')
+    if not len(item['user_skey']) == 40:
+        return json_error('the user_skey is invalid')
+
+    if item['user_skey'] != _get_user_key(item['user_hash'], item['app_id']):
+        _eventlog_add(_get_client_address(), item['user_hash'], None,
+                      'invalid user_skey of %s' % item['user_skey'], important=True)
+        #print('expected user_skey of %s' % _get_user_key(item['user_hash'], item['app_id']))
+        return json_error('invalid user_skey')
+
+    # the user already has a review
+    if _vote_exists(item['review_id'], item['user_hash']):
+        _eventlog_add(_get_client_address(), item['user_hash'], item['app_id'],
+                      'duplicate vote')
+        return json_error('already voted on this app')
+
+    # update the per-user karma
+    user = db.session.query(User).filter(User.user_hash == item['user_hash']).first()
+    if not user:
+        user = User(item['user_hash'])
+        db.session.add(user)
+    else:
+
+        # user is naughty
+        if user.is_banned:
+            return json_error('account has been disabled due to abuse')
+
+        # the user is too harsh
+        if val < 0 and user.karma < -50:
+            return json_error('odrs_all negative karma used up')
+    user.karma += val
+
+    review = db.session.query(Review).filter(Review.app_id == item['app_id']).first()
+    if not review:
+        _eventlog_add(_get_client_address(), item['user_hash'], None,
+                      'invalid review ID of %s' % item['app_id'], important=True)
+        return json_error('invalid review ID')
+
+    # update review
+    if val == -5:
+        review.reported += 1
+    elif val == 1:
+        review.karma_up += 1
+    elif val == -1:
+        review.karma_down += 1
+
+    db.session.commit()
+
+    # add the vote to the database
+    db.session.add(Vote(item['user_hash'], val, review_id=item['review_id']))
+    db.session.commit()
+    _eventlog_add(_get_client_address(), item['user_hash'], item['app_id'],
+                  'voted %i on review' % val)
+
+    return json_success('voted #%i %i' % (item['review_id'], val))
+
+@app.route('/1.0/reviews/api/upvote', methods=['POST'])
+def api_upvote():
+    """
+    Upvote an existing review by one karma point.
+    """
+    return _vote(1)
+
+@app.route('/1.0/reviews/api/downvote', methods=['POST'])
+def api_downvote():
+    """
+    Downvote an existing review by one karma point.
+    """
+    return _vote(-1)
+
+@app.route('/1.0/reviews/api/dismiss', methods=['POST'])
+def api_dismiss():
+    """
+    Dismiss a review without rating it up or down.
+    """
+    return _vote(0)
+
+@app.route('/1.0/reviews/api/report', methods=['POST'])
+def api_report():
+    """
+    Report a review for abuse.
+    """
+    return _vote(-5)
+
+@app.route('/1.0/reviews/api/remove', methods=['POST'])
+def api_remove():
+    """
+    Remove a review.
+    """
+    try:
+        item = json.loads(request.data.decode('utf8'))
+    except ValueError as e:
+        return json_error(str(e))
+    for key in ['review_id', 'app_id', 'user_hash', 'user_skey']:
+        if not key in item:
+            return json_error('invalid data, required %s' % key)
+        if not item[key]:
+            return json_error('missing data, expected %s' % key)
+
+    # check format
+    if not len(item['user_hash']) == 40:
+        return json_error('the user_hash is invalid')
+    if not len(item['user_skey']) == 40:
+        return json_error('the user_skey is invalid')
+
+    # the user already has a review
+    review = db.session.query(Review).\
+                filter(Review.review_id == item['review_id']).\
+                filter(Review.user_hash == item['user_hash']).first()
+    if not review:
+        return json_error('no review')
+    if review.app_id != item['app_id']:
+        return json_error('the app_id is invalid')
+
+    if item['user_skey'] != _get_user_key(item['user_hash'], item['app_id']):
+        _eventlog_add(_get_client_address(), item['user_hash'], None,
+                      'invalid user_skey of %s' % item['user_skey'], important=True)
+        return json_error('invalid user_skey')
+
+    db.session.delete(review)
+    db.session.commit()
+    _eventlog_add(_get_client_address(),
+                  item['user_hash'],
+                  item['app_id'],
+                  'removed review')
+    return json_success('removed review #%i' % item['review_id'])
+
+@app.route('/1.0/reviews/api/ratings/<app_id>')
+def api_rating_for_id(app_id):
+    """
+    Get the star ratings for a specific application.
+    """
+    ratings = _get_rating_for_app_id(app_id)
+    dat = json.dumps(ratings, sort_keys=True, indent=4, separators=(',', ': '))
+    return Response(response=dat,
+                    status=200, \
+                    mimetype='application/json')
+
+@app.route('/1.0/reviews/api/ratings')
+def api_ratings():
+    """
+    Get the star ratings for all known applications.
+    """
+    item = {}
+    app_ids = [res[0] for res in db.session.query(Review.app_id).\
+                                       order_by(Review.app_id.asc()).\
+                                       distinct(Review.app_id).all()]
+    for app_id in app_ids:
+        ratings = _get_rating_for_app_id(app_id, 2)
+        if len(ratings) == 0:
+            continue
+        item[app_id] = ratings
+
+    dat = json.dumps(item, sort_keys=True, indent=4, separators=(',', ': '))
+    return Response(response=dat,
+                    status=200, \
+                    mimetype='application/json')
diff --git a/app_data/pylint_test.py b/app_data/pylint_test.py
new file mode 100755
index 0000000..aaf9b92
--- /dev/null
+++ b/app_data/pylint_test.py
@@ -0,0 +1,40 @@
+#!/usr/bin/python3
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2018 Richard Hughes <richard hughsie com>
+#
+# SPDX-License-Identifier: GPL-2.0+
+
+import os
+import sys
+
+from glob import glob
+from pylint import epylint
+
+def main():
+
+    # find python files
+    filenames = [y for x in os.walk('.') for y in glob(os.path.join(x[0], '*.py'))]
+    rc = 0
+    argv = []
+    for fn in sorted(filenames):
+        if fn.find('migrations/') != -1:
+            continue
+        if fn.startswith('./env'):
+            continue
+        print('Checking %s' % fn)
+        argv.append(fn)
+
+    # run with 8 parallel tasks
+    argv.append('-j 8')
+    argv.append('--rcfile contrib/pylintrc')
+    (pylint_stdout, pylint_stderr) = epylint.py_run(' '.join(argv), return_std=True)
+    stderr = pylint_stderr.read()
+    stdout = pylint_stdout.read()
+    if stderr or stdout:
+        print(stderr, stdout)
+        rc = 1
+    return rc
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/app_data/requirements.txt b/app_data/requirements.txt
index f439a4b..b7e53c7 100644
--- a/app_data/requirements.txt
+++ b/app_data/requirements.txt
@@ -1,6 +1,8 @@
 flask-wtf
 flask-login
 markupsafe
+flask-migrate
 PyMySQL
 pylint
 pytest-cov
+sqlalchemy


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]