Merged in develop (pull request #53)

Version 0.4.1 release
master
Ensar Sarajcic 2018-11-03 17:14:51 +00:00
commit df32ec98f9
20 changed files with 304 additions and 35 deletions

1
.python-version 100644
View File

@ -0,0 +1 @@
3.6.6

View File

@ -129,6 +129,13 @@ class Role(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
display_name = db.Column(db.String, unique=True)
permissions = db.Column(db.ARRAY(db.String))
created_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp())
modified_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp(),
onupdate=db.func.current_timestamp())
def __init__(self, name, permissions):
self.display_name = str(name)

View File

@ -1,9 +1,11 @@
from flask_restful import Api
from marshmallow import ValidationError
from flask import Blueprint
from flask import Blueprint, jsonify
api_bp = Blueprint('api', __name__)
api = Api(api_bp)
@ -24,6 +26,7 @@ def add_resources():
DeviceTypeListResource,
DeviceConfigurationResource,
DeviceSecretResource,
DeviceSecretResetResource,
DeviceShareResource,
DeviceShareActivationResource)
from .resources.dashboard import (DashboardResource,
@ -55,6 +58,8 @@ def add_resources():
'/v1/devices/<int:device_id>/configuration')
api.add_resource(DeviceSecretResource,
'/v1/devices/<int:device_id>/secret')
api.add_resource(DeviceSecretResetResource,
'/v1/devices/<int:device_id>/secret/reset')
api.add_resource(DeviceShareResource,
'/v1/devices/<int:device_id>/share')
api.add_resource(
@ -76,12 +81,18 @@ add_resources()
@api_bp.errorhandler(ValidationError)
@api_bp.errorhandler(422)
def handle_validation_error(e):
return {'status': 'error', 'message': str(e)}, 422
return jsonify({'status': 'error', 'message': str(e)}), 422
@api_bp.errorhandler(ValueError)
def handle_value_error(e):
return jsonify({'status': 'error', 'message': str(e)}), 422
@api_bp.errorhandler(Exception)
@api_bp.errorhandler(500)
def handle_unknown_errors(e):
return ({
return jsonify({
'status': 'failed',
'message': 'Unknown error has occurred! ({0})'.format(str(e))
}, 500)
}), 500

View File

@ -9,11 +9,11 @@ from app.accounts.tasks import send_email_task
from app.api.auth_protection import ProtectedResource
from app.api.permission_protection import (requires_permission,
valid_permissions)
from app.api.schemas import BaseResourceSchema
from app.api.schemas import BaseTimestampedResourceSchema
from flask import current_app as app
class UserSchema(BaseResourceSchema):
class UserSchema(BaseTimestampedResourceSchema):
username = fields.Str(required=True)
email = fields.Email(required=True)
password = fields.Str(required=True, load_only=True)
@ -27,7 +27,7 @@ def validate_role_permissions(permissions_list):
return set(permissions_list).issubset(valid_permissions)
class RoleSchema(BaseResourceSchema):
class RoleSchema(BaseTimestampedResourceSchema):
id = fields.Integer(required=True, location='json')
display_name = fields.String(required=True, location='json')
permissions = fields.List(fields.String, required=True,

View File

@ -6,29 +6,32 @@ from flasgger import swag_from
import app.dashboards.api as dashboard
import app.devices.api as device
from app.api.auth_protection import ProtectedResource
from app.api.schemas import BaseResourceSchema
from app.api.schemas import (BaseResourceSchema,
BaseTimestampedSchema,
BaseTimestampedResourceSchema)
class BasicDashboardWidgetSchema(Schema):
class BasicDashboardWidgetSchema(BaseTimestampedSchema):
id = fields.Integer(dump_only=True)
device_id = fields.Integer()
height = fields.Integer()
width = fields.Integer()
x = fields.Integer()
y = fields.Integer()
chart_type = fields.String()
filters = fields.Raw()
device_id = fields.Integer(required=True)
name = fields.String(required=True)
height = fields.Integer(required=True)
width = fields.Integer(required=True)
x = fields.Integer(required=True)
y = fields.Integer(required=True)
chart_type = fields.String(required=True)
filters = fields.Raw(required=True)
class DashboardWidgetSchema(BaseResourceSchema, BasicDashboardWidgetSchema):
pass
class DashboardSchema(BaseResourceSchema):
class DashboardSchema(BaseTimestampedResourceSchema):
id = fields.Integer(dump_only=True)
active = fields.Boolean(required=False)
dashboard_data = fields.Raw()
name = fields.String()
dashboard_data = fields.Raw(required=True)
name = fields.String(required=True)
widgets = fields.Nested(BasicDashboardWidgetSchema, dump_only=True,
many=True)
@ -112,6 +115,7 @@ class DashboardWidgetListResource(ProtectedResource):
created_widget = dashboard.create_widget(
dashboard_id,
args['device_id'],
args['name'],
args['height'],
args['width'],
args['x'],
@ -142,6 +146,7 @@ class DashboardWidgetResource(ProtectedResource):
updated_widget = dashboard.patch_widget(
widget_id,
args['device_id'],
args['name'],
args['height'],
args['width'],
args['x'],
@ -159,6 +164,7 @@ class DashboardWidgetResource(ProtectedResource):
updated_widget = dashboard.patch_widget(
widget_id,
args.get('device_id'),
args.get('name'),
args.get('height'),
args.get('width'),
args.get('x'),

View File

@ -6,11 +6,13 @@ from flask import g, request, redirect
from app.api.blueprint import api
import app.devices.api as devices
from app.api.auth_protection import ProtectedResource
from app.api.schemas import BaseResourceSchema
from app.api.schemas import (BaseResourceSchema,
BaseTimestampedSchema,
BaseTimestampedResourceSchema)
from flask import current_app as app
class BasicDeviceTypeSchema(Schema):
class BasicDeviceTypeSchema(BaseTimestampedSchema):
id = fields.Integer(dump_only=True)
name = fields.Str(required=True)
@ -19,7 +21,7 @@ class DeviceTypeSchema(BaseResourceSchema, BasicDeviceTypeSchema):
pass
class DeviceSchema(BaseResourceSchema):
class DeviceSchema(BaseTimestampedResourceSchema):
id = fields.Integer(dump_only=True)
name = fields.Str(required=True)
device_type = fields.Nested(BasicDeviceTypeSchema, dump_only=True)
@ -45,7 +47,7 @@ class RecordingsQuerySchema(Schema):
class DeviceSecretSchema(BaseResourceSchema):
device_secret = fields.String(dump_only=True)
secret_algorithm = fields.String()
secret_algorithm = fields.String(required=True)
class DeviceShareSchema(BaseResourceSchema):
@ -171,6 +173,25 @@ class DeviceSecretResource(ProtectedResource):
validate_device_ownership(device_id)
return DeviceSecretSchema().dump(devices.get_device(device_id)), 200
@use_args(DeviceSecretSchema(), locations=('json',))
@swag_from('swagger/update_device_secret_spec.yaml')
def put(self, args, device_id):
validate_device_ownership(device_id)
return DeviceSecretSchema().dump(
devices.update_algorithm(
device_id,
args['secret_algorithm']
)
), 200
class DeviceSecretResetResource(ProtectedResource):
@swag_from('swagger/reset_device_secret_spec.yaml')
def post(self, device_id):
validate_device_ownership(device_id)
return DeviceSecretSchema().dump(
devices.reset_device_secret(device_id)), 200
class DeviceShareResource(ProtectedResource):
@use_args(DeviceShareSchema(), locations=('json',))

View File

@ -0,0 +1,20 @@
Resets a device secret info
---
tags:
- Device
parameters:
- in: path
name: device_id
required: true
type: integer
description: Id of the device
responses:
200:
description: Success
schema:
type: object
required:
- content
properties:
content:
$ref: '#/definitions/DeviceSecretInfo'

View File

@ -0,0 +1,26 @@
Updates device secret info (algorithm)
---
tags:
- Device
parameters:
- in: path
name: device_id
required: true
type: integer
description: Id of the device
- in: body
name: body
required: true
schema:
type: object
$ref: '#/definitions/DeviceSecretInfo'
responses:
200:
description: Success
schema:
type: object
required:
- content
properties:
content:
$ref: '#/definitions/DeviceSecretInfo'

View File

@ -1,7 +1,16 @@
from marshmallow import Schema, post_dump
from marshmallow import Schema, post_dump, fields
class BaseResourceSchema(Schema):
@post_dump(pass_many=True)
def wrap_with_envelope(self, data, many):
return {'content': data}
class BaseTimestampedSchema(Schema):
created_at = fields.DateTime(dump_only=True)
modified_at = fields.DateTime(dump_only=True)
class BaseTimestampedResourceSchema(BaseResourceSchema, BaseTimestampedSchema):
pass

View File

@ -1,5 +1,5 @@
# App initialization
from flask_api import FlaskAPI
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_bcrypt import Bcrypt
from flask_mail import Mail
@ -7,7 +7,7 @@ from flasgger import Swagger
from flask_cors import CORS
from .tasks import celery_configurator
app = FlaskAPI(__name__, instance_relative_config=True)
app = Flask(__name__, instance_relative_config=True)
app.config.from_object('config')
app.config.from_pyfile('config.py', silent=True)
db = SQLAlchemy(app)

View File

@ -106,12 +106,13 @@ def get_dashboards(account_id, active):
return Dashboard.get_many_filtered(account_id=account_id, active=active)
def create_widget(dashboard_id, device_id, height, width, x, y,
def create_widget(dashboard_id, device_id, name, height, width, x, y,
chart_type, filters):
"""
Tries to create a dashboard widget
"""
widget = DashboardWidget(dashboard_id, device_id, height, width, x, y,
widget = DashboardWidget(dashboard_id, device_id,
name, height, width, x, y,
chart_type, filters)
widget.save()
return widget
@ -152,7 +153,7 @@ def get_widget(widget_id):
return DashboardWidget.get(id=widget_id)
def patch_widget(widget_id, device_id=None, height=None, width=None,
def patch_widget(widget_id, device_id=None, name=None, height=None, width=None,
x=None, y=None, chart_type=None, filters=None):
"""
Tries to update widget with given parameters
@ -165,6 +166,9 @@ def patch_widget(widget_id, device_id=None, height=None, width=None,
if height is not None:
widget.height = height
if name is not None:
widget.name = name
if width is not None:
widget.width = width

View File

@ -126,6 +126,7 @@ class DashboardWidget(db.Model):
nullable=False)
device_id = db.Column(db.Integer, db.ForeignKey('devices.id'),
nullable=False)
name = db.Column(db.String, nullable=False)
height = db.Column(db.Integer, nullable=False)
width = db.Column(db.Integer, nullable=False)
x = db.Column(db.Integer, nullable=False)
@ -142,10 +143,11 @@ class DashboardWidget(db.Model):
dashboard = db.relationship("Dashboard", foreign_keys=[dashboard_id])
def __init__(self, dashboard_id, device_id, height, width, x, y,
def __init__(self, dashboard_id, device_id, name, height, width, x, y,
chart_type, filters):
self.dashboard_id = dashboard_id
self.device_id = device_id
self.name = name
self.height = height
self.width = width
self.x = x

View File

@ -1,6 +1,9 @@
import sys
import hmac
import urllib.parse
import datetime
import hashlib
from secrets import token_urlsafe
from .models import (Device,
Recording,
DeviceAssociation,
@ -149,6 +152,46 @@ def get_device(device_id):
return Device.get(id=device_id)
def reset_device_secret(device_id):
"""
Resets device secret for device with given parameters. Raises error on
failure
:param device_id: Id of device
:type device_id: int
:returns: Requested device
:rtype: Device
"""
device = Device.get(id=device_id)
device.device_secret = token_urlsafe(32)
device.save()
return device
def update_algorithm(device_id, algorithm):
"""
Updates device secret algorithm for device with given parameters. Raises
error on failure
:param device_id: Id of device
:type device_id: int
:param algorithm: Name of new algorithm
:type algorithm: string
:returns: Requested device
:rtype: Device
"""
if algorithm not in hashlib.algorithms_available:
raise ValueError("Unsupported algorithm! Supported algorithms: " +
str(hashlib.algorithms_available) + ". Some of " +
"these may not work on all platforms. These are " +
"guaranteed to work on every platform: " +
str(hashlib.algorithms_guaranteed))
device = Device.get(id=device_id)
device.secret_algorithm = algorithm
device.save()
return device
def can_user_access_device(account_id, device_id):
"""
Checks if user with given account_id can access device with given device_id
@ -357,6 +400,8 @@ def run_custom_query(device_id, request):
for row in result:
formatted_row = {}
for idx, col in enumerate(row):
if isinstance(col, datetime.datetime):
col = col.replace(tzinfo=datetime.timezone.utc).isoformat()
formatted_row[resulting_columns[idx]['name']] = col
formatted_result.append(formatted_row)
return formatted_result

View File

@ -225,6 +225,13 @@ class DeviceAssociation(db.Model):
primary_key=True)
access_level = db.Column(db.Integer, db.ForeignKey('access_levels.id'),
nullable=False)
created_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp())
modified_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp(),
onupdate=db.func.current_timestamp())
access_level_data = db.relationship("AccessLevel",
foreign_keys=[access_level])
@ -279,6 +286,13 @@ class DeviceType(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String, nullable=False)
created_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp())
modified_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp(),
onupdate=db.func.current_timestamp())
def __init__(self, name):
self.name = name
@ -333,6 +347,13 @@ class AccessLevel(db.Model):
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String, nullable=False)
permissions = db.Column(db.ARRAY(db.String), nullable=False)
created_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp())
modified_at = db.Column(db.DateTime,
nullable=False,
default=db.func.current_timestamp(),
onupdate=db.func.current_timestamp())
def __init__(self, name, permissions=['VIEW_DEVICE']):
self.name = name

View File

@ -7,11 +7,20 @@ ORDERS = ['asc', 'desc']
def run_query_on(query_object, field_provider, **kwargs):
"""
Generates a query for target object based on query provided as kwargs
:param query_object: Initial query object as returned by SQLAlchemy for
target table
:type query_object: Query
:param field_provider: Function which provides fields based on name, with
optional parameter formatted which returns the field formatted using sql
functions
:type field_provider: func(col_name:String, formatted:Boolean)
"""
selections, filters, groups, orderings = validate_selections(**kwargs)
entities = []
print('Starting with args: ' + str(kwargs))
if selections is not None:
if groups is not None:
for group in groups.keys():
@ -28,7 +37,6 @@ def run_query_on(query_object, field_provider, **kwargs):
entities.append(get_column(selections[selection],
field_provider(selection)).label(selection))
print('New entities: ' + str(entities))
query_object = query_object.with_entities(*entities)
if filters is not None:

View File

@ -219,6 +219,8 @@ definitions:
- id
- name
- device_type
- created_at
- modified_at
properties:
id:
$ref: '#/definitions/id'
@ -226,6 +228,10 @@ definitions:
$ref: '#/definitions/devicename'
device_type:
$ref: '#/definitions/DeviceType'
created_at:
$ref: '#/definitions/datetime'
modified_at:
$ref: '#/definitions/datetime'
DeviceShareTokenCreation:
type: object
@ -328,6 +334,7 @@ definitions:
- id
- dashboard_id
- device_id
- name
- width
- height
- x
@ -341,6 +348,8 @@ definitions:
$ref: '#/definitions/id'
device_id:
$ref: '#/definitions/id'
name:
$ref: '#/definitions/genericname'
width:
$ref: '#/definitions/id'
height:

View File

@ -2,7 +2,7 @@ import os
# App configuration
DEBUG = os.environ['DEBUG']
APP_VERSION = '0.4.0'
APP_VERSION = '0.4.1'
# Define the application directory
BASE_DIR = os.path.abspath(os.path.dirname(__file__))

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: 3cf41808886b
Revises: 764de3c39771
Create Date: 2018-11-03 15:40:04.384489
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3cf41808886b'
down_revision = '764de3c39771'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('dashboard_widgets', sa.Column('name', sa.String(),
nullable=False, server_default='Legacy widget'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('dashboard_widgets', 'name')
# ### end Alembic commands ###

View File

@ -0,0 +1,51 @@
"""empty message
Revision ID: 764de3c39771
Revises: 43e5ad1c4393
Create Date: 2018-11-03 15:00:36.463124
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '764de3c39771'
down_revision = '43e5ad1c4393'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('access_levels', sa.Column('created_at', sa.DateTime(),
nullable=False, server_default=sa.func.current_timestamp()))
op.add_column('access_levels', sa.Column('modified_at', sa.DateTime(),
nullable=False, server_default=sa.func.current_timestamp()))
op.add_column('device_associations', sa.Column('created_at', sa.DateTime(),
nullable=False, server_default=sa.func.current_timestamp()))
op.add_column('device_associations', sa.Column('modified_at',
sa.DateTime(), nullable=False,
server_default=sa.func.current_timestamp()))
op.add_column('device_types', sa.Column('created_at', sa.DateTime(),
nullable=False, server_default=sa.func.current_timestamp()))
op.add_column('device_types', sa.Column('modified_at', sa.DateTime(),
nullable=False, server_default=sa.func.current_timestamp()))
op.add_column('roles', sa.Column('created_at', sa.DateTime(),
nullable=False, server_default=sa.func.current_timestamp()))
op.add_column('roles', sa.Column('modified_at', sa.DateTime(),
nullable=False, server_default=sa.func.current_timestamp()))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('roles', 'modified_at')
op.drop_column('roles', 'created_at')
op.drop_column('device_types', 'modified_at')
op.drop_column('device_types', 'created_at')
op.drop_column('device_associations', 'modified_at')
op.drop_column('device_associations', 'created_at')
op.drop_column('access_levels', 'modified_at')
op.drop_column('access_levels', 'created_at')
# ### end Alembic commands ###

View File

@ -10,7 +10,6 @@ cffi==1.11.5
click==6.7
flasgger==0.8.3
Flask==1.0.2
Flask-API==1.0
Flask-Bcrypt==0.7.1
Flask-Cors==3.0.4
Flask-Mail==0.9.1