[odrs-web] Convert everything to sqlalchemy and add lots of nice unit tests
- From: Richard Hughes <rhughes src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [odrs-web] Convert everything to sqlalchemy and add lots of nice unit tests
- Date: Fri, 28 Jun 2019 13:24:27 +0000 (UTC)
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)}}">
«</a></li>
{% else %}
<li><span>«</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)
- }}">»</a></li>
+ <li><a href="{{url_for_other_page(pagination.page + 1)
+}}">»</a></li>
{% else %}
<li><span>»</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 }}"><<</a></li>
- <li><a href="/admin/review/{{ r.review_id + 1 }}">>></a></li>
+ <li><a href="{{url_for('.admin_show_review', review_id=r.review_id - 1)}}"><<</a></li>
+ <li><a href="{{url_for('.admin_show_review', review_id=r.review_id + 1)}}">>></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 }}↑, {{ r.karma_down }}↓
- {% 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}}↑, {{r.karma_down}}↓
+{% 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]