diff --git a/accountant/api/__init__.py b/accountant/api/__init__.py index b4ff6b7..9ce57af 100644 --- a/accountant/api/__init__.py +++ b/accountant/api/__init__.py @@ -16,12 +16,20 @@ """ from flask import Blueprint -from flask.ext.restful import Api +from flask.ext.restplus import Api from flask.ext.cors import CORS blueprint = Blueprint('api', __name__) -api = Api(blueprint) +authorizations = { + 'apikey': { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization' + } +} + +api = Api(blueprint, doc='/doc/', authorizations=authorizations) CORS(blueprint) # Load all views. diff --git a/accountant/api/fields.py b/accountant/api/fields.py index 3327060..dd2cd69 100644 --- a/accountant/api/fields.py +++ b/accountant/api/fields.py @@ -14,7 +14,7 @@ You should have received a copy of the GNU Affero General Public License along with Accountant. If not, see . """ -from flask.ext.restful import marshal, fields +from flask.ext.restplus import marshal, fields class Object(fields.Raw): @@ -24,17 +24,20 @@ class Object(fields.Raw): SQLAlchemy rows are viewed as tuples by Restful marshaller, and must be translated into a dict before marshaling. """ - def __init__(self, fields, **kwargs): + def __init__(self, model, **kwargs): """ - :param fields: field declaration. + :param model: the target model of the object. """ - self.fields = fields + self.model = model super(Object, self).__init__(**kwargs) def format(self, value): # First transform object in dict with fields in attribute. - result = {key: getattr(value, key, None) for key in self.fields.keys()} + result = {key: getattr(value, key, None) for key in self.model.keys()} # Marshal the dict - return marshal(result, self.fields) + return marshal(result, self.model) + + def schema(self): + return self.model.__schema__ diff --git a/accountant/api/views/accounts.py b/accountant/api/views/accounts.py index f02b370..8531f5a 100644 --- a/accountant/api/views/accounts.py +++ b/accountant/api/views/accounts.py @@ -14,7 +14,7 @@ You should have received a copy of the GNU Affero General Public License along with Accountant. If not, see . """ -from flask.ext.restful import Resource, fields, marshal_with_field +from flask.ext.restplus import Resource, fields, marshal_with_field from accountant import db @@ -27,12 +27,22 @@ from ..fields import Object from .models import (account_model, solds_model, balance_model, category_model, ohlc_model) -from .parsers import account_parser, range_parser +from .parsers import range_parser from .users import requires_auth +ns = api.namespace('account', description='Account management') + + +@ns.route('/') +@api.doc( + security='apikey', + responses={ + 401: 'Unauthorized' + }) class AccountListResource(Resource): @requires_auth + @api.response(200, 'OK', [account_model]) @marshal_with_field(fields.List(Object(account_model))) def get(self): """ @@ -41,51 +51,92 @@ class AccountListResource(Resource): return Account.query().all(), 200 @requires_auth + @api.expect(account_model) + @api.response(201, 'Account created', account_model) + @api.response(406, 'Invalid account data') @marshal_with_field(Object(account_model)) def post(self): """ Create a new account. """ - data = account_parser.parse_args() + data = api.payload + # A new account MUST NOT have an id; + if 'id' in data and data['id']: + api.abort( + 406, + error_message='Id must not be provided on creation.' + ) + + # Instantiate account with data. account = Account(**data) + # Add new account in session. db.session.add(account) # Flush session to have id in account. db.session.flush() - # Return account data. + # Return account. return account, 201 +@ns.route('/') +@api.doc( + security='apikey', + params={ + 'id': 'Id of the account to manage' + }, + responses={ + 401: 'Unauthorized', + 404: 'Account not found' + }) class AccountResource(Resource): @requires_auth + @api.response(200, 'OK', account_model) @marshal_with_field(Object(account_model)) def get(self, id): """ - Get account. + Get an account. """ account = Account.query().get(id) if not account: - return None, 404 + api.abort( + 404, + error_message='Account with id %d not found.' % id + ) + # Note: if we don't pass the code, the result is seen as a tuple and + # causes error on marshalling. return account, 200 @requires_auth + @api.expect(account_model) + @api.response(200, 'OK', account_model) + @api.response(406, 'Invalid account data') @marshal_with_field(Object(account_model)) def post(self, id): - data = account_parser.parse_args() + """ + Update an account. + """ + data = api.payload - assert (id not in data or data.id is None - or data.id == id) + # Check ID consistency. + if 'id' in data and data['id'] and data['id'] != id: + api.abort( + 406, + error_message='Id must not be provided or changed on update.' + ) # Need to get the object to update it. account = Account.query().get(id) if not account: - return None, 404 + api.abort( + 404, + error_message='Account with id %d not found.' % id + ) # SQLAlchemy objects ignore __dict__.update() with merge. for k, v in data.items(): @@ -97,25 +148,37 @@ class AccountResource(Resource): return account, 200 @requires_auth + @api.response(204, 'Account deleted', account_model) @marshal_with_field(Object(account_model)) def delete(self, id): + """ + Delete an account. + """ + # Need to get the object to update it. account = Account.query().get(id) if not account: - return None, 404 + api.abort( + 404, + error_message='Account with id %d not found.' % id + ) db.session.delete(account) return None, 204 -api.add_resource(AccountListResource, '/account') -api.add_resource(AccountResource, '/account/') - - +@ns.route('//solds') class SoldsResource(Resource): @requires_auth + @api.doc( + security='apikey', + responses={ + 200: ('OK', solds_model), + 401: 'Unauthorized', + 404: 'Account not found' + }) @marshal_with_field(Object(solds_model)) def get(self, id): """ @@ -124,15 +187,27 @@ class SoldsResource(Resource): account = Account.query().get(id) if not account: - return None, 404 + api.abort( + 404, + error_message='Account with id %d not found.' % id + ) # Note: if we don't pass the code, the result is seen as a tuple and # causes error on marshalling. return account.solds(), 200 +@ns.route('//balance') class BalanceResource(Resource): @requires_auth + @api.doc( + security='apikey', + responses={ + 200: ('OK', balance_model), + 401: 'Unauthorized', + 404: 'Account not found' + }) + @api.expect(range_parser) @marshal_with_field(Object(balance_model)) def get(self, id): """ @@ -141,7 +216,10 @@ class BalanceResource(Resource): account = Account.query().get(id) if not account: - return None, 404 + api.abort( + 404, + error_message='Account with id %d not found.' % id + ) data = range_parser.parse_args() @@ -150,25 +228,43 @@ class BalanceResource(Resource): return account.balance(**data), 200 +@ns.route("//category") class CategoryResource(Resource): @requires_auth + @api.doc( + security='apikey', + responses={ + 200: ('OK', [category_model]), + 401: 'Unauthorized', + 404: 'Account not found' + }) + @api.expect(range_parser) @marshal_with_field(fields.List(Object(category_model))) def get(self, id): + """ + Get account category balances for a specific date range. + """ data = range_parser.parse_args() return Operation.get_categories_for_range(id, **data).all() +@ns.route('//ohlc') class OHLCResource(Resource): @requires_auth + @api.doc( + security='apikey', + responses={ + 200: ('OK', [ohlc_model]), + 401: 'Unauthorized', + 404: 'Account not found' + }) + @api.expect(range_parser) @marshal_with_field(fields.List(Object(ohlc_model))) def get(self, id): + """ + Get OHLC data for a specific date range and account. + """ data = range_parser.parse_args() return Operation.get_ohlc_per_day_for_range(id, **data).all() - - -api.add_resource(SoldsResource, "/account//solds") -api.add_resource(BalanceResource, "/account//balance") -api.add_resource(CategoryResource, "/account//category") -api.add_resource(OHLCResource, "/account//ohlc") diff --git a/accountant/api/views/models.py b/accountant/api/views/models.py index 81f3946..661e81d 100644 --- a/accountant/api/views/models.py +++ b/accountant/api/views/models.py @@ -14,87 +14,217 @@ You should have received a copy of the GNU Affero General Public License along with Accountant. If not, see . """ -from copy import deepcopy +from flask.ext.restplus import fields -from flask.ext.restful import fields +from .. import api # Account model. -account_model = { - 'id': fields.Integer(default=None), - 'name': fields.String, - 'authorized_overdraft': fields.Float, -} +account_model = api.model('Account', { + 'id': fields.Integer( + default=None, + readonly=True, + description='Id of the account'), + 'name': fields.String( + required=True, + description='Name of the account'), + 'authorized_overdraft': fields.Float( + required=True, + description='Authorized overdraft') +}) # Account status model. -solds_model = { - 'current': fields.Float, - 'pointed': fields.Float, - 'future': fields.Float, -} +solds_model = api.model('Solds', { + 'current': fields.Float( + readonly=True, + description='Current sold of the account'), + 'pointed': fields.Float( + readonly=True, + description='Pointed sold of the account'), + 'future': fields.Float( + readonly=True, + description='Future sold of the account') +}) # Account balance model. -balance_model = { - 'expenses': fields.Float, - 'revenues': fields.Float, - 'balance': fields.Float, -} +balance_model = api.model('Balance', { + 'expenses': fields.Float( + readonly=True, + description='Total amount of expenses'), + 'revenues': fields.Float( + readonly=True, + description='Total amount of revenues'), + 'balance': fields.Float( + readonly=True, + description='Balance'), +}) # Category with expenses and revenues. -category_model = { - 'category': fields.String, - 'expenses': fields.Float, - 'revenues': fields.Float -} +category_model = api.model('Category', { + 'category': fields.String( + readonly=True, + description='Category name'), + 'expenses': fields.Float( + readonly=True, + description='Total amount of expenses for the category'), + 'revenues': fields.Float( + readonly=True, + description='Total amount of revenues for the category') +}) # OHLC model. -ohlc_model = { - 'operation_date': fields.DateTime(dt_format='iso8601'), - 'open': fields.Float, - 'high': fields.Float, - 'low': fields.Float, - 'close': fields.Float -} +ohlc_model = api.model('OHLC', { + 'operation_date': fields.DateTime( + dt_format='iso8601', + readonly=True, + required=True, + description='Date of the OHLC object' + ), + 'open': fields.Float( + readonly=True, + required=True, + description='Open value' + ), + 'high': fields.Float( + readonly=True, + required=True, + description='High value' + ), + 'low': fields.Float( + readonly=True, + required=True, + description='Low value' + ), + 'close': fields.Float( + readonly=True, + required=True, + description='Close value' + ) +}) # Operation with sold model. -operation_model = { - 'id': fields.Integer(default=None), - 'operation_date': fields.DateTime(dt_format='iso8601'), - 'label': fields.String, - 'value': fields.Float, - 'pointed': fields.Boolean, - 'category': fields.String, - 'account_id': fields.Integer, - 'scheduled_operation_id': fields.Integer(default=None), - 'confirmed': fields.Boolean, - 'canceled': fields.Boolean, -} +operation_model = api.model('Operation', { + 'id': fields.Integer( + default=None, + readonly=True, + description='Id of the operation'), + 'operation_date': fields.DateTime( + dt_format='iso8601', + required=True, + description='Date of the operation'), + 'label': fields.String( + required=True, + description='Label of the operation'), + 'value': fields.Float( + required=True, + description='Value of the operation'), + 'pointed': fields.Boolean( + required=True, + description='Pointed status of the operation'), + 'category': fields.String( + required=False, + default=None, + description='Category of the operation'), + 'account_id': fields.Integer( + required=True, + readonly=True, + description='Account id of the operation'), + 'scheduled_operation_id': fields.Integer( + default=None, + readonly=True, + description='Scheduled operation ID of the operation'), + 'confirmed': fields.Boolean( + description='Confirmed status of the operation'), + 'canceled': fields.Boolean( + description='Canceled status of the operation (for a scheduled one)') +}) -operation_with_sold_model = deepcopy(operation_model) -operation_with_sold_model['sold'] = fields.Float +operation_with_sold_model = api.extend( + 'OperationWithSold', operation_model, { + 'sold': fields.Float( + readonly=True, + description='Cumulated sold' + ), + } +) # Scheduled operation model. -scheduled_operation_model = { - 'id': fields.Integer, - 'start_date': fields.DateTime(dt_format='iso8601'), - 'stop_date': fields.DateTime(dt_format='iso8601'), - 'day': fields.Integer, - 'frequency': fields.Integer, - 'label': fields.String, - 'value': fields.Float, - 'category': fields.String, - 'account_id': fields.Integer, -} +scheduled_operation_model = api.model('ScheduledOperation', { + 'id': fields.Integer( + description='Id of the scheduled operation', + readonly=True, + default=None), + 'start_date': fields.DateTime( + dt_format='iso8601', + required=True, + description='Start date of the scheduled operation'), + 'stop_date': fields.DateTime( + dt_format='iso8601', + required=True, + description='End date of the scheduled operation'), + 'day': fields.Integer( + required=True, + description='Day of month for the scheduled operation'), + 'frequency': fields.Integer( + required=True, + description='Frequency of the scheduling in months'), + 'label': fields.String( + required=True, + description='Label of the generated operations'), + 'value': fields.Float( + required=True, + description='Value of the generated operations'), + 'category': fields.String( + required=False, + description='Category of the generated operations'), + 'account_id': fields.Integer( + default=None, + readonly=True, + required=True, + description='Account id of the scheduled operation'), +}) # Token with expiration time and type. -token_model = { - 'token': fields.String, - 'expiration': fields.DateTime(dt_format='iso8601'), - 'token_type': fields.String -} +token_model = api.model('Token', { + 'token': fields.String( + required=True, + readonly=True, + description='Token value'), + 'expiration': fields.DateTime( + dt_format='iso8601', + required=True, + readonly=True, + description='Expiration time of the token'), + 'token_type': fields.String( + required=True, + readonly=True, + description='Token type') +}) # User model. -user_model = { - 'id': fields.Integer(default=None), - 'email': fields.String, - 'active': fields.Boolean -} +user_model = api.model('User', { + 'id': fields.Integer( + default=None, + required=True, + readonly=True, + description='Id of the user'), + 'email': fields.String( + required=True, + readonly=True, + decription='Email address of the user'), + 'active': fields.Boolean( + required=True, + readonly=True, + description='Active state of the user') +}) + +# Login model. +login_model = api.model('Login', { + 'email': fields.String( + required=True, + description='Email to use for login' + ), + 'password': fields.String( + required=True, + description='Plain text password to use for login' + ) +}) diff --git a/accountant/api/views/operations.py b/accountant/api/views/operations.py index 1438c8f..a8e2c7a 100644 --- a/accountant/api/views/operations.py +++ b/accountant/api/views/operations.py @@ -14,25 +14,40 @@ You should have received a copy of the GNU Affero General Public License along with Accountant. If not, see . """ -from flask.ext.restful import Resource, fields, marshal_with_field +from flask.ext.restplus import Resource, fields, marshal_with_field from accountant import db from .. import api +from ..models.accounts import Account from ..models.operations import Operation from .models import operation_model, operation_with_sold_model -from .parsers import operation_parser, account_range_parser +from .parsers import account_range_parser from .users import requires_auth from ..fields import Object +ns = api.namespace('operation', description='Operation management') + + +@ns.route('/') +@api.doc( + security='apikey', + responses={ + 401: 'Unauthorized' + }) class OperationListResource(Resource): @requires_auth + @api.response(200, 'OK', [operation_with_sold_model]) + @api.expect(parser=account_range_parser) @marshal_with_field(fields.List(Object(operation_with_sold_model))) def get(self): + """ + Get operations with solds for a specific account. + """ data = account_range_parser.parse_args() return Operation.query( @@ -43,9 +58,31 @@ class OperationListResource(Resource): ).all(), 200 @requires_auth + @api.response(201, 'Operation created', operation_model) + @api.response(404, 'Account not found') + @api.response(406, 'Invalid operation data') @marshal_with_field(Object(operation_model)) def post(self): - data = operation_parser.parse_args() + """ + Create a new operation. + """ + data = api.payload + + account_id = data['account_id'] + account = Account.query().get(account_id) + + if not account: + api.abort( + 404, + error_message='Account with id %d not found.' % account_id + ) + + # A new operation MUST NOT have an id; + if 'id' in data and data['id']: + api.abort( + 406, + error_message='Id must not be provided on creation.' + ) operation = Operation(**data) @@ -54,8 +91,19 @@ class OperationListResource(Resource): return operation, 201 +@ns.route('/') +@api.doc( + security='apikey', + params={ + 'id': 'Id of the operation to manage' + }, + responses={ + 401: 'Unauthorized', + 404: 'Operation not found' + }) class OperationResource(Resource): @requires_auth + @api.response(200, 'OK', operation_model) @marshal_with_field(Object(operation_model)) def get(self, id): """ @@ -64,22 +112,37 @@ class OperationResource(Resource): operation = db.session.query(Operation).get(id) if not operation: - return None, 404 + api.abort( + 404, + error_message='Operation with id %d not found.' % id + ) return operation, 200 @requires_auth + @api.expect(operation_model) + @api.response(200, 'OK', operation_model) + @api.response(406, 'Invalid operation data') @marshal_with_field(Object(operation_model)) def post(self, id): - data = operation_parser.parse_args() + data = api.payload - assert (id not in data or data.id is None - or data.id == id) + # Check ID consistency. + if 'id' in data and data['id'] and data['id'] != id: + api.abort( + 406, + error_message='Id must not be provided or changed on update.' + ) operation = db.session.query(Operation).get(id) if not operation: - return None, 404 + api.abort( + 404, + error_message='Operation with id %d not found.' % id + ) + + # FIXME check account_id consistency. # SQLAlchemy objects ignore __dict__.update() with merge. for k, v in data.items(): @@ -90,17 +153,17 @@ class OperationResource(Resource): return operation, 200 @requires_auth + @api.response(204, 'Operation deleted', operation_model) @marshal_with_field(Object(operation_model)) def delete(self, id): operation = db.session.query(Operation).get(id) if not operation: - return None, 404 + api.abort( + 404, + error_message='Operation with id %d not found.' % id + ) db.session.delete(operation) return None, 204 - - -api.add_resource(OperationListResource, "/operation") -api.add_resource(OperationResource, "/operation/") diff --git a/accountant/api/views/parsers.py b/accountant/api/views/parsers.py index 9209343..0ddab64 100644 --- a/accountant/api/views/parsers.py +++ b/accountant/api/views/parsers.py @@ -19,61 +19,39 @@ import dateutil.parser from flask.ext.restful import reqparse +from .. import api + # Parser for a date range. -range_parser = reqparse.RequestParser() +range_parser = api.parser() range_parser.add_argument( 'begin', - type=lambda a: dateutil.parser.parse(a) if a else None + type=lambda a: dateutil.parser.parse(a) if a else None, + required=False, + default=None, + location='args', + help='Begin date of the time period' ) range_parser.add_argument( 'end', - type=lambda a: dateutil.parser.parse(a) if a else None + type=lambda a: dateutil.parser.parse(a) if a else None, + required=False, + default=None, + location='args', + help='End date of the time period' ) # Parser for an account id. account_id_parser = reqparse.RequestParser() -account_id_parser.add_argument('account_id', type=int) +account_id_parser.add_argument( + 'account_id', + type=int, + required=True, + location='args', + help='Id of the account' +) # Parser for a date range and an account id. account_range_parser = range_parser.copy() account_range_parser.add_argument( deepcopy(account_id_parser.args[0]) ) - -# Parser for an account. -account_parser = reqparse.RequestParser() -account_parser.add_argument('name', type=str, required=True) -account_parser.add_argument('authorized_overdraft', type=float, required=True) - -# Parser for an operation. -operation_parser = reqparse.RequestParser() -# Must use lambda because the parser passes other parameters badly interpreted -# by dateutil.parser.parse -operation_parser.add_argument( - 'operation_date', type=lambda a: dateutil.parser.parse(a)) -operation_parser.add_argument('label', type=str) -operation_parser.add_argument('value', type=float) -operation_parser.add_argument('pointed', type=bool) -operation_parser.add_argument('category', type=str) -operation_parser.add_argument('account_id', type=int) -operation_parser.add_argument('scheduled_operation_id', type=int) -operation_parser.add_argument('confirmed', type=bool) -operation_parser.add_argument('canceled', type=bool) - -# Parser for a scheduled operation. -scheduled_operation_parser = reqparse.RequestParser() -scheduled_operation_parser.add_argument( - 'start_date', type=lambda a: dateutil.parser.parse(a)) -scheduled_operation_parser.add_argument( - 'stop_date', type=lambda a: dateutil.parser.parse(a)) -scheduled_operation_parser.add_argument('day', type=int) -scheduled_operation_parser.add_argument('frequency', type=int) -scheduled_operation_parser.add_argument('label', type=str) -scheduled_operation_parser.add_argument('value', type=float) -scheduled_operation_parser.add_argument('category', type=str) -scheduled_operation_parser.add_argument('account_id', type=int) - -# Parser for a login. -login_parser = reqparse.RequestParser() -login_parser.add_argument('email', type=str, required=True) -login_parser.add_argument('password', type=str, required=True) diff --git a/accountant/api/views/scheduled_operations.py b/accountant/api/views/scheduled_operations.py index 0ddf71b..4497bdd 100644 --- a/accountant/api/views/scheduled_operations.py +++ b/accountant/api/views/scheduled_operations.py @@ -14,26 +14,41 @@ You should have received a copy of the GNU Affero General Public License along with Accountant. If not, see . """ -from flask.ext.restful import Resource, fields, marshal_with_field +from flask.ext.restplus import Resource, fields, marshal_with_field from sqlalchemy import true from accountant import db -from ..models.scheduled_operations import ScheduledOperation -from ..models.operations import Operation - from .. import api +from ..models.accounts import Account +from ..models.operations import Operation +from ..models.scheduled_operations import ScheduledOperation + from .models import scheduled_operation_model -from .parsers import account_id_parser, scheduled_operation_parser +from .parsers import account_id_parser from .users import requires_auth from ..fields import Object +ns = api.namespace( + 'scheduled_operation', + description='Scheduled operation management' +) + + +@ns.route('/') +@api.doc( + security='apikey', + responses={ + 401: 'Unauthorized', + }) class ScheduledOperationListResource(Resource): @requires_auth + @api.expect(account_id_parser) + @api.response(200, 'OK', [scheduled_operation_model]) @marshal_with_field(fields.List(Object(scheduled_operation_model))) def get(self): """ @@ -44,12 +59,32 @@ class ScheduledOperationListResource(Resource): return ScheduledOperation.query().filter_by(**data).all(), 200 @requires_auth + @api.expect(scheduled_operation_model) + @api.response(200, 'OK', scheduled_operation_model) + @api.response(404, 'Account not found') + @api.response(406, 'Invalid operation data') @marshal_with_field(Object(scheduled_operation_model)) def post(self): """ Add a new scheduled operation. """ - data = scheduled_operation_parser.parse_args() + data = api.payload + + account_id = data['account_id'] + account = Account.query().get(account_id) + + if not account: + api.abort( + 404, + error_message='Account with id %d not found.' % account_id + ) + + # A new scheduled operation MUST NOT have an id; + if 'id' in data and data['id']: + api.abort( + 406, + error_message='Id must not be provided on creation.' + ) scheduled_operation = ScheduledOperation(**data) @@ -62,8 +97,19 @@ class ScheduledOperationListResource(Resource): return scheduled_operation, 201 +@ns.route('/') +@api.doc( + security='apikey', + params={ + 'id': 'Id of the scheduled operation to manage' + }, + responses={ + 401: 'Unauthorized', + 404: 'Scheduled operation not found' + }) class ScheduledOperationResource(Resource): @requires_auth + @api.response(200, 'OK', scheduled_operation_model) @marshal_with_field(Object(scheduled_operation_model)) def get(self, id): """ @@ -72,25 +118,40 @@ class ScheduledOperationResource(Resource): scheduled_operation = ScheduledOperation.query().get(id) if not scheduled_operation: - return None, 404 + api.abort( + 404, + error_message='Scheduled operation with id %d not found.' % id + ) return scheduled_operation, 200 @requires_auth + @api.response(200, 'OK', scheduled_operation_model) + @api.response(406, 'Invalid scheduled operation data') + @api.expect(scheduled_operation_model) @marshal_with_field(Object(scheduled_operation_model)) def post(self, id): """ Update a scheduled operation. """ - data = scheduled_operation_parser.parse_args() + data = api.payload - assert (id not in data or data.id is None - or data.id == id) + # Check ID consistency. + if 'id' in data and data['id'] and data['id'] != id: + api.abort( + 406, + error_message='Id must not be provided or changed on update.' + ) scheduled_operation = ScheduledOperation.query().get(id) if not scheduled_operation: - return None, 404 + api.abort( + 404, + error_message='Scheduled operation with id %d not found.' % id + ) + + # FIXME check account_id consistency. # SQLAlchemy objects ignore __dict__.update() with merge. for k, v in data.items(): @@ -105,6 +166,8 @@ class ScheduledOperationResource(Resource): return scheduled_operation, 200 @requires_auth + @api.response(200, 'OK', scheduled_operation_model) + @api.response(409, 'Cannot be deleted') @marshal_with_field(Object(scheduled_operation_model)) def delete(self, id): """ @@ -113,24 +176,24 @@ class ScheduledOperationResource(Resource): scheduled_operation = ScheduledOperation.query().get(id) if not scheduled_operation: - return None, 404 + api.abort( + 404, + error_message='Scheduled operation with id %d not found.' % id + ) operations = scheduled_operation.operations.filter( Operation.confirmed == true() ).count() if operations: - return "There are still confirmed operations associated to this \ - scheduled operation.", 409 + api.abort( + 409, + error_message='There are still confirmed operations \ + associated to this scheduled operation.') # Delete unconfirmed operations - operations = scheduled_operation.operations.delete() + scheduled_operation.operations.delete() db.session.delete(scheduled_operation) return None, 204 - - -api.add_resource(ScheduledOperationListResource, "/scheduled_operation") -api.add_resource(ScheduledOperationResource, - "/scheduled_operation/") diff --git a/accountant/api/views/users.py b/accountant/api/views/users.py index 77078f6..fed6eea 100644 --- a/accountant/api/views/users.py +++ b/accountant/api/views/users.py @@ -20,7 +20,7 @@ import arrow from functools import wraps from flask import request, g -from flask.ext.restful import Resource, marshal_with, marshal_with_field +from flask.ext.restplus import Resource, marshal_with_field from accountant import app @@ -30,8 +30,7 @@ from ..fields import Object from ..models.users import User -from .models import token_model, user_model -from .parsers import login_parser +from .models import user_model, token_model, login_model def load_user_from_token(token): @@ -43,10 +42,6 @@ def load_user_from_auth(auth): return load_user_from_token(token) -def authenticate(): - return {'error': 'Please login before executing this request.'}, 401 - - def requires_auth(f): @wraps(f) def wrapped(*args, **data): @@ -60,24 +55,37 @@ def requires_auth(f): g.user = user return f(*args, **data) - return authenticate() + api.abort( + 401, + error_message='Please login before executing this request.' + ) return wrapped +ns = api.namespace('user', description='User management') + + +@ns.route('/login') class LoginResource(Resource): - @marshal_with(token_model) + @api.marshal_with(token_model) + @api.doc( + responses={ + 200: ('OK', token_model), + 401: 'Unauthorized' + }) + @api.expect(login_model) def post(self): """ Login to retrieve authentication token. """ - data = login_parser.parse_args() + data = api.payload user = User.query().filter( User.email == data['email'] ).one_or_none() if not user or not user.verify_password(data['password']): - authenticate() + api.abort(401, error_message="Bad user or password.") token = user.generate_auth_token() expiration_time = arrow.now().replace( @@ -91,9 +99,14 @@ class LoginResource(Resource): }, 200 @requires_auth + @api.doc( + security='apikey', + responses={ + 200: ('OK', user_model) + }) @marshal_with_field(Object(user_model)) def get(self): + """ + Get authenticated user information. + """ return g.user, 200 - - -api.add_resource(LoginResource, "/user/login") diff --git a/requirements.txt b/requirements.txt index 9fec9cf..29dac8a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ Flask-Assets==0.11 Flask-Bower==1.2.1 Flask-Login==0.2.11 Flask-Migrate==1.5.1 -Flask-RESTful==0.3.4 +flask-restplus=0.8.6 flask-cors=2.1.2 Flask-Script==2.0.5 Flask-SQLAlchemy==2.0