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