From b85e90cfb4b546529e122ae109d9243fdd4f6b92 Mon Sep 17 00:00:00 2001 From: esensar Date: Tue, 8 May 2018 16:45:09 +0200 Subject: [PATCH] Add basic device endpoints --- app/accounts/__init__.py | 16 ++-- app/accounts/controllers.py | 64 --------------- app/accounts/models.py | 8 +- app/api/__init__.py | 13 ++++ app/api/resources/account.py | 29 ++++--- app/api/resources/device.py | 50 ++++++++++++ .../swagger/create_account_spec.yaml | 4 - .../resources/swagger/create_device_spec.yaml | 19 +++++ .../resources/swagger/get_account_spec.yaml | 29 +++---- .../swagger/get_device_recordings_spec.yaml | 23 ++++++ .../resources/swagger/get_device_spec.yaml | 21 +++++ app/devices/__init__.py | 67 ++++++++++++---- app/devices/models.py | 9 +++ app/devices/mqtt_client.py | 77 ++++++++----------- app/swagger/template.yaml | 46 +++++++++++ 15 files changed, 311 insertions(+), 164 deletions(-) delete mode 100644 app/accounts/controllers.py create mode 100644 app/api/resources/device.py create mode 100644 app/api/resources/swagger/create_device_spec.yaml create mode 100644 app/api/resources/swagger/get_device_recordings_spec.yaml create mode 100644 app/api/resources/swagger/get_device_spec.yaml diff --git a/app/accounts/__init__.py b/app/accounts/__init__.py index 773d0cf..655e6c8 100644 --- a/app/accounts/__init__.py +++ b/app/accounts/__init__.py @@ -15,9 +15,9 @@ def create_account(username, email, password): :type username: string :type email: string :type password: string - :returns True if account is successfully created - :rtype Boolean - :raises ValueError if account already exists + :returns: True if account is successfully created + :rtype: Boolean + :raises: ValueError if account already exists """ if not Account.exists_with_any_of(username=username, email=email): pw_hash = bcrypt.generate_password_hash(password).decode('utf-8') @@ -37,9 +37,9 @@ def create_token(username, password): :param password: password of Account :type username: string :type password: string - :returns created token - :rtype string - :raises ValueError if credentials are invalid or account does not exist + :returns: created token + :rtype: string + :raises: ValueError if credentials are invalid or account does not exist """ if not Account.exists(username=username): raise ValueError("Invalid credentials") @@ -57,7 +57,7 @@ def validate_token(token): :param token: auth token to validate :type token: string - :returns created token - :rtype Account + :returns: created token + :rtype: Account """ return Account.validate_token(token) diff --git a/app/accounts/controllers.py b/app/accounts/controllers.py deleted file mode 100644 index abc3f6d..0000000 --- a/app/accounts/controllers.py +++ /dev/null @@ -1,64 +0,0 @@ -from app import bcrypt, status -from flask import request -from .models import Account - - -def initialize_routes(accounts): - @accounts.route("", methods=['POST']) - def create_account(): - print(request.data) - user = request.data.get('user') - if not Account.exists_with_any_of( - username=user.get('username'), email=user.get('email')): - password_hash = bcrypt.generate_password_hash( - user.get('password') - ).decode('utf-8') - acct = Account(user.get('username'), - password_hash, - user.get('email')) - acct.save() - response = { - 'status': 'success', - 'message': 'Success!' - } - return response, status.HTTP_200_OK - else: - response = { - 'status': 'error', - 'message': 'User already exists!' - } - return response, status.HTTP_422_UNPROCESSABLE_ENTITY - - @accounts.route("/token", methods=['POST']) - def create_token(): - print(request.data) - user = request.data.get('user') - if not user: - response = { - 'status': 'error', - 'message': 'Invalid request' - } - return response, status.HTTP_400_BAD_REQUEST - - if not Account.exists(username=user.get('username')): - response = { - 'status': 'error', - 'message': 'Invalid credentials' - } - return response, status.HTTP_401_UNAUTHORIZED - - account = Account.get(username=user.get('username')) - if not bcrypt.check_password_hash( - account.password, user.get('password')): - response = { - 'status': 'error', - 'message': 'Invalid credentials' - } - return response, status.HTTP_401_UNAUTHORIZED - - response = { - 'status': 'success', - 'message': 'Successfully logged in', - 'token': account.create_auth_token() - } - return response, status.HTTP_200_OK diff --git a/app/accounts/models.py b/app/accounts/models.py index 90a0858..f366615 100644 --- a/app/accounts/models.py +++ b/app/accounts/models.py @@ -1,6 +1,7 @@ import jwt import datetime from app import db, app +from calendar import timegm class Account(db.Model): @@ -108,10 +109,13 @@ class Account(db.Model): app.config.get('SECRET_KEY'), algorithms=['HS256'] ) + current_time = timegm(datetime.datetime.utcnow().utctimetuple()) + if current_time > payload['exp']: + raise ValueError("Expired token") return Account.get(id=payload['sub']) def __repr__(self): - return '' % self.username, self.role + return '' % (self.username, self.role) class Role(db.Model): @@ -146,4 +150,4 @@ class Role(db.Model): return Role.query.filter_by(id=roleId) def __repr__(self): - return '' % self.name + return '' % self.display_name diff --git a/app/api/__init__.py b/app/api/__init__.py index 12968e6..b307e92 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1,3 +1,4 @@ +import sys from flask import Blueprint, request, g from flask_restful import Api, Resource, abort from functools import wraps @@ -22,8 +23,13 @@ def protected(func): if not g.current_account: abort(401, message='Unauthorized', status='error') except Exception: + error_type, error_instance, traceback = sys.exc_info() + print(str(error_type)) + print(str(error_instance)) abort(401, message='Unauthorized', status='error') + return func(*args, **kwargs) + return protected_function @@ -34,10 +40,17 @@ class ProtectedResource(Resource): def add_resources(): from .resources.account import AccountResource, AccountListResource from .resources.token import TokenResource + from .resources.device import (DeviceResource, + DeviceRecordingResource, + DeviceListResource) api.add_resource(AccountResource, '/v1/accounts/') api.add_resource(AccountListResource, '/v1/accounts') api.add_resource(TokenResource, '/v1/token') + api.add_resource(DeviceResource, '/v1/devices/') + api.add_resource(DeviceRecordingResource, + '/v1/devices//recordings') + api.add_resource(DeviceListResource, '/v1/devices') add_resources() diff --git a/app/api/resources/account.py b/app/api/resources/account.py index c4f769a..11fc505 100644 --- a/app/api/resources/account.py +++ b/app/api/resources/account.py @@ -1,31 +1,32 @@ from flask_restful import Resource, abort from flask import g -from webargs import fields +from marshmallow import Schema, fields from webargs.flaskparser import use_args from flasgger import swag_from import app.accounts as accounts -from app.api import ProtectedResource, protected +from app.api import ProtectedResource -user_args = { - 'user': fields.Nested({ - 'username': fields.Str(required=True), - 'email': fields.Email(required=True), - 'password': fields.Str(required=True) - }, required=True, location='json') -} + +class UserSchema(Schema): + username = fields.Str(required=True) + email = fields.Email(required=True) + password = fields.Str(required=True, load_only=True) + + +class UserWrapperSchema(Schema): + user = fields.Nested(UserSchema, required=True, location='json') class AccountResource(ProtectedResource): - @swag_from('swagger/get_account_spec.yaml') def get(self, account_id): if g.current_account.id == account_id: - return g.current_account, 200 + return UserWrapperSchema().dump({'user': g.current_account}), 200 abort(403, message='You can only get your own account', status='error') class AccountListResource(Resource): - @use_args(user_args) + @use_args(UserWrapperSchema()) @swag_from('swagger/create_account_spec.yaml') def post(self, args): try: @@ -38,7 +39,3 @@ class AccountListResource(Resource): return '', 201 except ValueError: abort(422, message='Account already exists', status='error') - - @protected - def get(self): - return '', 200 diff --git a/app/api/resources/device.py b/app/api/resources/device.py new file mode 100644 index 0000000..78229c2 --- /dev/null +++ b/app/api/resources/device.py @@ -0,0 +1,50 @@ +from marshmallow import Schema, fields +from webargs.flaskparser import use_args +from flasgger import swag_from +import app.devices as devices +from app.api import ProtectedResource + + +class DeviceSchema(Schema): + id = fields.Integer(dump_only=True) + name = fields.Str(required=True) + + +class DeviceWrapperSchema(Schema): + device = fields.Nested(DeviceSchema, required=True, location='json') + + +class RecordingsSchema(Schema): + recorded_at = fields.DateTime() + record_type = fields.Integer() + record_value = fields.String() + + +class RecordingsWrapperSchema(Schema): + recordings = fields.Nested(RecordingsSchema, required=True, + location='json', many=True) + + +class DeviceResource(ProtectedResource): + @swag_from('swagger/get_device_spec.yaml') + def get(self, device_id): + return DeviceWrapperSchema().dump( + {'device': devices.get_device(device_id)}), 200 + + +class DeviceRecordingResource(ProtectedResource): + @swag_from('swagger/get_device_recordings_spec.yaml') + def get(self, device_id): + return RecordingsWrapperSchema().dump( + {'recordings': devices.get_device_recordings(device_id)}), 200 + + +class DeviceListResource(ProtectedResource): + @use_args(DeviceWrapperSchema()) + @swag_from('swagger/create_device_spec.yaml') + def post(self, args): + args = args['device'] + success = devices.create_device( + args['name']) + if success: + return '', 201 diff --git a/app/api/resources/swagger/create_account_spec.yaml b/app/api/resources/swagger/create_account_spec.yaml index e610aa4..99190bc 100644 --- a/app/api/resources/swagger/create_account_spec.yaml +++ b/app/api/resources/swagger/create_account_spec.yaml @@ -14,10 +14,6 @@ parameters: - user properties: user: - required: - - username - - password - - email $ref: '#/definitions/User' security: [] responses: diff --git a/app/api/resources/swagger/create_device_spec.yaml b/app/api/resources/swagger/create_device_spec.yaml new file mode 100644 index 0000000..1bb1850 --- /dev/null +++ b/app/api/resources/swagger/create_device_spec.yaml @@ -0,0 +1,19 @@ +Creates new device +Requires Device object and creates device +--- +tags: + - Device +parameters: + - in: body + name: body + required: true + schema: + type: object + required: + - device + properties: + device: + $ref: '#/definitions/Device' +responses: + 201: + description: Successful creation diff --git a/app/api/resources/swagger/get_account_spec.yaml b/app/api/resources/swagger/get_account_spec.yaml index 1150dc3..217d865 100644 --- a/app/api/resources/swagger/get_account_spec.yaml +++ b/app/api/resources/swagger/get_account_spec.yaml @@ -1,22 +1,25 @@ Gets a user account +User may only get own account. Accessing other accounts will return 403. --- tags: - Account parameters: - - in: body - name: body + - in: path + name: user_id required: true - schema: - type: object - required: - - user - properties: - user: - $ref: '#/definitions/User' + type: integer + description: Id of the user responses: - 201: - description: Successful creation - 422: - description: Account already exists + 200: + description: Success + schema: + type: object + required: + - user + properties: + user: + $ref: '#/definitions/User' + 403: + description: Accessed a different account schema: $ref: '#/definitions/Error' diff --git a/app/api/resources/swagger/get_device_recordings_spec.yaml b/app/api/resources/swagger/get_device_recordings_spec.yaml new file mode 100644 index 0000000..a22f095 --- /dev/null +++ b/app/api/resources/swagger/get_device_recordings_spec.yaml @@ -0,0 +1,23 @@ +Gets all recordings for given device +--- +tags: + - Device + - Recording +parameters: + - in: path + name: device_id + required: true + type: integer + description: Id of the device +responses: + 200: + description: Success + schema: + type: object + required: + - recordings + properties: + recordings: + type: array + items: + $ref: '#/definitions/Recording' diff --git a/app/api/resources/swagger/get_device_spec.yaml b/app/api/resources/swagger/get_device_spec.yaml new file mode 100644 index 0000000..e3d7bea --- /dev/null +++ b/app/api/resources/swagger/get_device_spec.yaml @@ -0,0 +1,21 @@ +Gets a device +--- +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: + -device + properties: + device: + $ref: '#/definitions/Device' + diff --git a/app/devices/__init__.py b/app/devices/__init__.py index bf9faa4..0de869f 100644 --- a/app/devices/__init__.py +++ b/app/devices/__init__.py @@ -1,30 +1,71 @@ import atexit from flask import Blueprint from .mqtt_client import MqttClient +from .models import Device, Recording devices_bp = Blueprint('devices', __name__) -mqtt_client = None # When app dies, stop mqtt connection def on_stop(): - if mqtt_client: - mqtt_client.tear_down() + MqttClient.tear_down() atexit.register(on_stop) -# Routes -@devices_bp.route("/") -def hello(): - return "Hello from devices!" - - +# Setup @devices_bp.record -def on_blueprint_setup(setup_state): +def __on_blueprint_setup(setup_state): print('Blueprint setup') - mqtt_client = MqttClient() + MqttClient.setup(setup_state.app) - if mqtt_client: - mqtt_client.setup(setup_state.app) + +# Public interface +def create_device(name, device_type=1): + """ + Tries to create device with given parameters + + :param name: Desired device name + :param device_type: Id of desired device type. + By default it is 1 (STANDARD) + :type name: string + :type device_type: int + :returns: True if device is successfully created + :rtype: Boolean + """ + device = Device(name, None, device_type) + device.save() + + +def get_device_recordings(device_id): + """ + Tries to get device recording for device with given parameters. Raises + error on failure + + :param device_id: Id of device + :type device_id: int + :returns: List of Recordings for given device + :rtype: List of Recording + :raises: ValueError if device does not exist + """ + if not Device.exists(id=device_id): + raise ValueError("Device with id %s does not exist" % device_id) + + return Recording.get_many(device_id=device_id) + + +def get_device(device_id): + """ + Tries to get device with given parameters. Raises error on failure + + :param device_id: Id of device + :type device_id: int + :returns: Requested device + :rtype: Device + :raises: ValueError if device does not exist + """ + if not Device.exists(id=device_id): + raise ValueError("Device with id %s does not exist" % device_id) + + return Device.get(id=device_id) diff --git a/app/devices/models.py b/app/devices/models.py index 4500e5a..24e80fb 100644 --- a/app/devices/models.py +++ b/app/devices/models.py @@ -137,6 +137,15 @@ class Device(db.Model): """ return Device.query.filter_by(**kwargs).first() + @staticmethod + def exists(**kwargs): + """ + Checks if device with all of the given arguments exists + """ + if Device.query.filter_by(**kwargs).first(): + return True + return False + def __repr__(self): return '' % ( self.name, self.device_type_id) diff --git a/app/devices/mqtt_client.py b/app/devices/mqtt_client.py index fae3a30..579c9bc 100644 --- a/app/devices/mqtt_client.py +++ b/app/devices/mqtt_client.py @@ -2,66 +2,55 @@ import sys import json from flask_mqtt import Mqtt from .models import Recording -from app import db, app +from app import app + class MqttClient: - class __MqttClient: - def __init__(self): - self.mqtt = Mqtt() - self.initialized = False - def __str__(self): - return repr(self) - - - instance = None - - - def __init__(self): - if not MqttClient.instance: - MqttClient.instance = MqttClient.__MqttClient() - - - def __getattr__(self, name): - return getattr(self.instance, name) + __initialized = False + mqtt = Mqtt() # Mqtt setup - def setup(self, app): - if not self.initialized: - self.mqtt.init_app(app) - self.mqtt.client.on_message = self.handle_mqtt_message - self.mqtt.client.on_subscribe = self.handle_subscribe - initialized = True + @staticmethod + def setup(app): + if not MqttClient.__initialized: + MqttClient.mqtt.init_app(app) + MqttClient.mqtt.client.on_message = MqttClient.handle_mqtt_message + MqttClient.mqtt.client.on_subscribe = MqttClient.handle_subscribe + MqttClient.__initialized = True - @self.mqtt.on_connect() + @MqttClient.mqtt.on_connect() def handle_connect(client, userdata, flags, rc): print('MQTT client connected') - self.mqtt.subscribe('device/+') + MqttClient.mqtt.subscribe('device/+') - @self.mqtt.on_disconnect() + @MqttClient.mqtt.on_disconnect() def handle_disconnect(): print('MQTT client disconnected') print('MQTT client initialized') + @staticmethod + def tear_down(): + if MqttClient.__initialized: + MqttClient.mqtt.unsubscribe_all() + if (hasattr(MqttClient.mqtt, 'client') and + MqttClient.mqtt.client is not None): + MqttClient.mqtt.client.disconnect() + print('MQTT client destroyed') - def tear_down(self): - self.mqtt.unsubscribe_all() - if hasattr(self.mqtt, 'client') and self.mqtt.client is not None: - self.mqtt.client.disconnect() - print('MQTT client destroyed') - - - def handle_subscribe(self, client, userdata, mid, granted_qos): + @staticmethod + def handle_subscribe(client, userdata, mid, granted_qos): print('MQTT client subscribed') - - def handle_mqtt_message(self, client, userdata, message): + @staticmethod + def handle_mqtt_message(client, userdata, message): print("Received message!") print("Topic: " + message.topic) print("Payload: " + message.payload.decode()) try: # If type is JSON - recording = self.parse_json_message(message.topic, message.payload.decode()) + recording = MqttClient.parse_json_message( + message.topic, message.payload.decode()) with app.app_context(): recording.save() except ValueError: @@ -71,11 +60,11 @@ class MqttClient: print("Instance: " + str(error_instance)) return - - def parse_json_message(self, topic, payload) -> Recording: + @staticmethod + def parse_json_message(topic, payload) -> Recording: try: json_msg = json.loads(payload) - device_id = self.get_device_id(topic) + device_id = MqttClient.get_device_id(topic) return Recording(device_id=device_id, record_type=json_msg["record_type"], record_value=json_msg["record_value"], @@ -86,8 +75,8 @@ class MqttClient: raise ValueError("JSON parsing failed! Key error: " + str(error_instance)) - - def get_device_id(self, topic) -> int: + @staticmethod + def get_device_id(topic) -> int: device_token, device_id = topic.split("/") if device_token == "device": return int(device_id) diff --git a/app/swagger/template.yaml b/app/swagger/template.yaml index a1ccd66..291372e 100644 --- a/app/swagger/template.yaml +++ b/app/swagger/template.yaml @@ -12,6 +12,25 @@ definitions: description: User's name in the system default: testusername + id: + type: integer + description: ID + default: 1 + + datetime: + type: string + description: Time + + devicename: + type: string + description: Name of device + default: My device + + devicetype: + type: int + description: Type of device + default: 1 + email: type: string format: email @@ -57,6 +76,33 @@ definitions: email: $ref: '#/definitions/email' + Recording: + type: object + required: + - recorded_at + - record_type + - record_value + properties: + recorded_at: + $ref: '#/definitions/datetime' + record_type: + $ref: '#/definitions/id' + record_value: + type: string + description: Value of the recording + default: '25 degrees' + + Device: + type: object + required: + - id + - name + properties: + id: + $ref: '#/definitions/id' + name: + $ref: '#/definitions/devicename' + UnauthorizedError: type: object required: