diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..4f2c1d1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.6.6 diff --git a/app/accounts/models.py b/app/accounts/models.py index d205226..102b02d 100644 --- a/app/accounts/models.py +++ b/app/accounts/models.py @@ -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) diff --git a/app/api/blueprint.py b/app/api/blueprint.py index 5dc5918..488e8ec 100644 --- a/app/api/blueprint.py +++ b/app/api/blueprint.py @@ -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//configuration') api.add_resource(DeviceSecretResource, '/v1/devices//secret') + api.add_resource(DeviceSecretResetResource, + '/v1/devices//secret/reset') api.add_resource(DeviceShareResource, '/v1/devices//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 diff --git a/app/api/resources/account.py b/app/api/resources/account.py index b36a49a..5b4e66e 100644 --- a/app/api/resources/account.py +++ b/app/api/resources/account.py @@ -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, diff --git a/app/api/resources/dashboard.py b/app/api/resources/dashboard.py index 9c4ada8..2b80f88 100644 --- a/app/api/resources/dashboard.py +++ b/app/api/resources/dashboard.py @@ -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'), diff --git a/app/api/resources/device.py b/app/api/resources/device.py index 664431e..3cad303 100644 --- a/app/api/resources/device.py +++ b/app/api/resources/device.py @@ -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',)) diff --git a/app/api/resources/swagger/reset_device_secret_spec.yaml b/app/api/resources/swagger/reset_device_secret_spec.yaml new file mode 100644 index 0000000..3219ac0 --- /dev/null +++ b/app/api/resources/swagger/reset_device_secret_spec.yaml @@ -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' diff --git a/app/api/resources/swagger/update_device_secret_spec.yaml b/app/api/resources/swagger/update_device_secret_spec.yaml new file mode 100644 index 0000000..24f7e88 --- /dev/null +++ b/app/api/resources/swagger/update_device_secret_spec.yaml @@ -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' diff --git a/app/api/schemas.py b/app/api/schemas.py index 8f4a228..2d12e43 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -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 diff --git a/app/core.py b/app/core.py index 32002f2..bda72ff 100644 --- a/app/core.py +++ b/app/core.py @@ -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) diff --git a/app/dashboards/api.py b/app/dashboards/api.py index 0e4a819..21af487 100644 --- a/app/dashboards/api.py +++ b/app/dashboards/api.py @@ -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 diff --git a/app/dashboards/models.py b/app/dashboards/models.py index 091052f..5e6aa61 100644 --- a/app/dashboards/models.py +++ b/app/dashboards/models.py @@ -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 diff --git a/app/devices/api.py b/app/devices/api.py index 514de4b..8b5fb7f 100644 --- a/app/devices/api.py +++ b/app/devices/api.py @@ -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 diff --git a/app/devices/models.py b/app/devices/models.py index e21d9ed..dac9c1a 100644 --- a/app/devices/models.py +++ b/app/devices/models.py @@ -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 diff --git a/app/jsonql/api.py b/app/jsonql/api.py index e051343..8cf31e3 100644 --- a/app/jsonql/api.py +++ b/app/jsonql/api.py @@ -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: diff --git a/app/swagger/template.yaml b/app/swagger/template.yaml index f45f263..5d98951 100644 --- a/app/swagger/template.yaml +++ b/app/swagger/template.yaml @@ -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: diff --git a/config.py b/config.py index acf60d4..e844357 100644 --- a/config.py +++ b/config.py @@ -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__)) diff --git a/migrations/versions/3cf41808886b_.py b/migrations/versions/3cf41808886b_.py new file mode 100644 index 0000000..80f338e --- /dev/null +++ b/migrations/versions/3cf41808886b_.py @@ -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 ### diff --git a/migrations/versions/764de3c39771_.py b/migrations/versions/764de3c39771_.py new file mode 100644 index 0000000..52e5859 --- /dev/null +++ b/migrations/versions/764de3c39771_.py @@ -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 ### diff --git a/requirements.txt b/requirements.txt index 0ccdb5a..171b2e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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