Convert to flask-restplus and document for swagger.

This commit is contained in:
Alexis Lahouze 2016-01-19 08:29:25 +01:00
parent 4b1313e65c
commit bb708e81b3
9 changed files with 539 additions and 185 deletions

View File

@ -16,12 +16,20 @@
""" """
from flask import Blueprint from flask import Blueprint
from flask.ext.restful import Api from flask.ext.restplus import Api
from flask.ext.cors import CORS from flask.ext.cors import CORS
blueprint = Blueprint('api', __name__) blueprint = Blueprint('api', __name__)
api = Api(blueprint)
authorizations = {
'apikey': {
'type': 'apiKey',
'in': 'header',
'name': 'Authorization'
}
}
api = Api(blueprint, doc='/doc/', authorizations=authorizations)
CORS(blueprint) CORS(blueprint)
# Load all views. # Load all views.

View File

@ -14,7 +14,7 @@
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with Accountant. If not, see <http://www.gnu.org/licenses/>. along with Accountant. If not, see <http://www.gnu.org/licenses/>.
""" """
from flask.ext.restful import marshal, fields from flask.ext.restplus import marshal, fields
class Object(fields.Raw): class Object(fields.Raw):
@ -24,17 +24,20 @@ class Object(fields.Raw):
SQLAlchemy rows are viewed as tuples by Restful marshaller, and must be SQLAlchemy rows are viewed as tuples by Restful marshaller, and must be
translated into a dict before marshaling. 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) super(Object, self).__init__(**kwargs)
def format(self, value): def format(self, value):
# First transform object in dict with fields in attribute. # 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 # Marshal the dict
return marshal(result, self.fields) return marshal(result, self.model)
def schema(self):
return self.model.__schema__

View File

@ -14,7 +14,7 @@
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with Accountant. If not, see <http://www.gnu.org/licenses/>. along with Accountant. If not, see <http://www.gnu.org/licenses/>.
""" """
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 accountant import db
@ -27,12 +27,22 @@ from ..fields import Object
from .models import (account_model, solds_model, balance_model, from .models import (account_model, solds_model, balance_model,
category_model, ohlc_model) category_model, ohlc_model)
from .parsers import account_parser, range_parser from .parsers import range_parser
from .users import requires_auth from .users import requires_auth
ns = api.namespace('account', description='Account management')
@ns.route('/')
@api.doc(
security='apikey',
responses={
401: 'Unauthorized'
})
class AccountListResource(Resource): class AccountListResource(Resource):
@requires_auth @requires_auth
@api.response(200, 'OK', [account_model])
@marshal_with_field(fields.List(Object(account_model))) @marshal_with_field(fields.List(Object(account_model)))
def get(self): def get(self):
""" """
@ -41,51 +51,92 @@ class AccountListResource(Resource):
return Account.query().all(), 200 return Account.query().all(), 200
@requires_auth @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)) @marshal_with_field(Object(account_model))
def post(self): def post(self):
""" """
Create a new account. 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) account = Account(**data)
# Add new account in session.
db.session.add(account) db.session.add(account)
# Flush session to have id in account. # Flush session to have id in account.
db.session.flush() db.session.flush()
# Return account data. # Return account.
return account, 201 return account, 201
@ns.route('/<int:id>')
@api.doc(
security='apikey',
params={
'id': 'Id of the account to manage'
},
responses={
401: 'Unauthorized',
404: 'Account not found'
})
class AccountResource(Resource): class AccountResource(Resource):
@requires_auth @requires_auth
@api.response(200, 'OK', account_model)
@marshal_with_field(Object(account_model)) @marshal_with_field(Object(account_model))
def get(self, id): def get(self, id):
""" """
Get account. Get an account.
""" """
account = Account.query().get(id) account = Account.query().get(id)
if not account: 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 return account, 200
@requires_auth @requires_auth
@api.expect(account_model)
@api.response(200, 'OK', account_model)
@api.response(406, 'Invalid account data')
@marshal_with_field(Object(account_model)) @marshal_with_field(Object(account_model))
def post(self, id): 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 # Check ID consistency.
or data.id == id) 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. # Need to get the object to update it.
account = Account.query().get(id) account = Account.query().get(id)
if not account: 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. # SQLAlchemy objects ignore __dict__.update() with merge.
for k, v in data.items(): for k, v in data.items():
@ -97,25 +148,37 @@ class AccountResource(Resource):
return account, 200 return account, 200
@requires_auth @requires_auth
@api.response(204, 'Account deleted', account_model)
@marshal_with_field(Object(account_model)) @marshal_with_field(Object(account_model))
def delete(self, id): def delete(self, id):
"""
Delete an account.
"""
# Need to get the object to update it. # Need to get the object to update it.
account = Account.query().get(id) account = Account.query().get(id)
if not account: if not account:
return None, 404 api.abort(
404,
error_message='Account with id %d not found.' % id
)
db.session.delete(account) db.session.delete(account)
return None, 204 return None, 204
api.add_resource(AccountListResource, '/account') @ns.route('/<int:id>/solds')
api.add_resource(AccountResource, '/account/<int:id>')
class SoldsResource(Resource): class SoldsResource(Resource):
@requires_auth @requires_auth
@api.doc(
security='apikey',
responses={
200: ('OK', solds_model),
401: 'Unauthorized',
404: 'Account not found'
})
@marshal_with_field(Object(solds_model)) @marshal_with_field(Object(solds_model))
def get(self, id): def get(self, id):
""" """
@ -124,15 +187,27 @@ class SoldsResource(Resource):
account = Account.query().get(id) account = Account.query().get(id)
if not account: 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 # Note: if we don't pass the code, the result is seen as a tuple and
# causes error on marshalling. # causes error on marshalling.
return account.solds(), 200 return account.solds(), 200
@ns.route('/<int:id>/balance')
class BalanceResource(Resource): class BalanceResource(Resource):
@requires_auth @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)) @marshal_with_field(Object(balance_model))
def get(self, id): def get(self, id):
""" """
@ -141,7 +216,10 @@ class BalanceResource(Resource):
account = Account.query().get(id) account = Account.query().get(id)
if not account: if not account:
return None, 404 api.abort(
404,
error_message='Account with id %d not found.' % id
)
data = range_parser.parse_args() data = range_parser.parse_args()
@ -150,25 +228,43 @@ class BalanceResource(Resource):
return account.balance(**data), 200 return account.balance(**data), 200
@ns.route("/<int:id>/category")
class CategoryResource(Resource): class CategoryResource(Resource):
@requires_auth @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))) @marshal_with_field(fields.List(Object(category_model)))
def get(self, id): def get(self, id):
"""
Get account category balances for a specific date range.
"""
data = range_parser.parse_args() data = range_parser.parse_args()
return Operation.get_categories_for_range(id, **data).all() return Operation.get_categories_for_range(id, **data).all()
@ns.route('/<int:id>/ohlc')
class OHLCResource(Resource): class OHLCResource(Resource):
@requires_auth @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))) @marshal_with_field(fields.List(Object(ohlc_model)))
def get(self, id): def get(self, id):
"""
Get OHLC data for a specific date range and account.
"""
data = range_parser.parse_args() data = range_parser.parse_args()
return Operation.get_ohlc_per_day_for_range(id, **data).all() return Operation.get_ohlc_per_day_for_range(id, **data).all()
api.add_resource(SoldsResource, "/account/<int:id>/solds")
api.add_resource(BalanceResource, "/account/<int:id>/balance")
api.add_resource(CategoryResource, "/account/<int:id>/category")
api.add_resource(OHLCResource, "/account/<int:id>/ohlc")

View File

@ -14,87 +14,217 @@
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with Accountant. If not, see <http://www.gnu.org/licenses/>. along with Accountant. If not, see <http://www.gnu.org/licenses/>.
""" """
from copy import deepcopy from flask.ext.restplus import fields
from flask.ext.restful import fields from .. import api
# Account model. # Account model.
account_model = { account_model = api.model('Account', {
'id': fields.Integer(default=None), 'id': fields.Integer(
'name': fields.String, default=None,
'authorized_overdraft': fields.Float, 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. # Account status model.
solds_model = { solds_model = api.model('Solds', {
'current': fields.Float, 'current': fields.Float(
'pointed': fields.Float, readonly=True,
'future': fields.Float, 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. # Account balance model.
balance_model = { balance_model = api.model('Balance', {
'expenses': fields.Float, 'expenses': fields.Float(
'revenues': fields.Float, readonly=True,
'balance': fields.Float, 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 with expenses and revenues.
category_model = { category_model = api.model('Category', {
'category': fields.String, 'category': fields.String(
'expenses': fields.Float, readonly=True,
'revenues': fields.Float 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.
ohlc_model = { ohlc_model = api.model('OHLC', {
'operation_date': fields.DateTime(dt_format='iso8601'), 'operation_date': fields.DateTime(
'open': fields.Float, dt_format='iso8601',
'high': fields.Float, readonly=True,
'low': fields.Float, required=True,
'close': fields.Float 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 with sold model.
operation_model = { operation_model = api.model('Operation', {
'id': fields.Integer(default=None), 'id': fields.Integer(
'operation_date': fields.DateTime(dt_format='iso8601'), default=None,
'label': fields.String, readonly=True,
'value': fields.Float, description='Id of the operation'),
'pointed': fields.Boolean, 'operation_date': fields.DateTime(
'category': fields.String, dt_format='iso8601',
'account_id': fields.Integer, required=True,
'scheduled_operation_id': fields.Integer(default=None), description='Date of the operation'),
'confirmed': fields.Boolean, 'label': fields.String(
'canceled': fields.Boolean, 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 = api.extend(
operation_with_sold_model['sold'] = fields.Float 'OperationWithSold', operation_model, {
'sold': fields.Float(
readonly=True,
description='Cumulated sold'
),
}
)
# Scheduled operation model. # Scheduled operation model.
scheduled_operation_model = { scheduled_operation_model = api.model('ScheduledOperation', {
'id': fields.Integer, 'id': fields.Integer(
'start_date': fields.DateTime(dt_format='iso8601'), description='Id of the scheduled operation',
'stop_date': fields.DateTime(dt_format='iso8601'), readonly=True,
'day': fields.Integer, default=None),
'frequency': fields.Integer, 'start_date': fields.DateTime(
'label': fields.String, dt_format='iso8601',
'value': fields.Float, required=True,
'category': fields.String, description='Start date of the scheduled operation'),
'account_id': fields.Integer, '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 with expiration time and type.
token_model = { token_model = api.model('Token', {
'token': fields.String, 'token': fields.String(
'expiration': fields.DateTime(dt_format='iso8601'), required=True,
'token_type': fields.String 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.
user_model = { user_model = api.model('User', {
'id': fields.Integer(default=None), 'id': fields.Integer(
'email': fields.String, default=None,
'active': fields.Boolean 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'
)
})

View File

@ -14,25 +14,40 @@
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with Accountant. If not, see <http://www.gnu.org/licenses/>. along with Accountant. If not, see <http://www.gnu.org/licenses/>.
""" """
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 accountant import db
from .. import api from .. import api
from ..models.accounts import Account
from ..models.operations import Operation from ..models.operations import Operation
from .models import operation_model, operation_with_sold_model 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 .users import requires_auth
from ..fields import Object from ..fields import Object
ns = api.namespace('operation', description='Operation management')
@ns.route('/')
@api.doc(
security='apikey',
responses={
401: 'Unauthorized'
})
class OperationListResource(Resource): class OperationListResource(Resource):
@requires_auth @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))) @marshal_with_field(fields.List(Object(operation_with_sold_model)))
def get(self): def get(self):
"""
Get operations with solds for a specific account.
"""
data = account_range_parser.parse_args() data = account_range_parser.parse_args()
return Operation.query( return Operation.query(
@ -43,9 +58,31 @@ class OperationListResource(Resource):
).all(), 200 ).all(), 200
@requires_auth @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)) @marshal_with_field(Object(operation_model))
def post(self): 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) operation = Operation(**data)
@ -54,8 +91,19 @@ class OperationListResource(Resource):
return operation, 201 return operation, 201
@ns.route('/<int:id>')
@api.doc(
security='apikey',
params={
'id': 'Id of the operation to manage'
},
responses={
401: 'Unauthorized',
404: 'Operation not found'
})
class OperationResource(Resource): class OperationResource(Resource):
@requires_auth @requires_auth
@api.response(200, 'OK', operation_model)
@marshal_with_field(Object(operation_model)) @marshal_with_field(Object(operation_model))
def get(self, id): def get(self, id):
""" """
@ -64,22 +112,37 @@ class OperationResource(Resource):
operation = db.session.query(Operation).get(id) operation = db.session.query(Operation).get(id)
if not operation: if not operation:
return None, 404 api.abort(
404,
error_message='Operation with id %d not found.' % id
)
return operation, 200 return operation, 200
@requires_auth @requires_auth
@api.expect(operation_model)
@api.response(200, 'OK', operation_model)
@api.response(406, 'Invalid operation data')
@marshal_with_field(Object(operation_model)) @marshal_with_field(Object(operation_model))
def post(self, id): def post(self, id):
data = operation_parser.parse_args() data = api.payload
assert (id not in data or data.id is None # Check ID consistency.
or data.id == id) 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) operation = db.session.query(Operation).get(id)
if not operation: 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. # SQLAlchemy objects ignore __dict__.update() with merge.
for k, v in data.items(): for k, v in data.items():
@ -90,17 +153,17 @@ class OperationResource(Resource):
return operation, 200 return operation, 200
@requires_auth @requires_auth
@api.response(204, 'Operation deleted', operation_model)
@marshal_with_field(Object(operation_model)) @marshal_with_field(Object(operation_model))
def delete(self, id): def delete(self, id):
operation = db.session.query(Operation).get(id) operation = db.session.query(Operation).get(id)
if not operation: if not operation:
return None, 404 api.abort(
404,
error_message='Operation with id %d not found.' % id
)
db.session.delete(operation) db.session.delete(operation)
return None, 204 return None, 204
api.add_resource(OperationListResource, "/operation")
api.add_resource(OperationResource, "/operation/<int:id>")

View File

@ -19,61 +19,39 @@ import dateutil.parser
from flask.ext.restful import reqparse from flask.ext.restful import reqparse
from .. import api
# Parser for a date range. # Parser for a date range.
range_parser = reqparse.RequestParser() range_parser = api.parser()
range_parser.add_argument( range_parser.add_argument(
'begin', '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( range_parser.add_argument(
'end', '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. # Parser for an account id.
account_id_parser = reqparse.RequestParser() 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. # Parser for a date range and an account id.
account_range_parser = range_parser.copy() account_range_parser = range_parser.copy()
account_range_parser.add_argument( account_range_parser.add_argument(
deepcopy(account_id_parser.args[0]) 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)

View File

@ -14,26 +14,41 @@
You should have received a copy of the GNU Affero General Public License You should have received a copy of the GNU Affero General Public License
along with Accountant. If not, see <http://www.gnu.org/licenses/>. along with Accountant. If not, see <http://www.gnu.org/licenses/>.
""" """
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 sqlalchemy import true
from accountant import db from accountant import db
from ..models.scheduled_operations import ScheduledOperation
from ..models.operations import Operation
from .. import api 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 .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 .users import requires_auth
from ..fields import Object 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): class ScheduledOperationListResource(Resource):
@requires_auth @requires_auth
@api.expect(account_id_parser)
@api.response(200, 'OK', [scheduled_operation_model])
@marshal_with_field(fields.List(Object(scheduled_operation_model))) @marshal_with_field(fields.List(Object(scheduled_operation_model)))
def get(self): def get(self):
""" """
@ -44,12 +59,32 @@ class ScheduledOperationListResource(Resource):
return ScheduledOperation.query().filter_by(**data).all(), 200 return ScheduledOperation.query().filter_by(**data).all(), 200
@requires_auth @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)) @marshal_with_field(Object(scheduled_operation_model))
def post(self): def post(self):
""" """
Add a new scheduled operation. 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) scheduled_operation = ScheduledOperation(**data)
@ -62,8 +97,19 @@ class ScheduledOperationListResource(Resource):
return scheduled_operation, 201 return scheduled_operation, 201
@ns.route('/<int:id>')
@api.doc(
security='apikey',
params={
'id': 'Id of the scheduled operation to manage'
},
responses={
401: 'Unauthorized',
404: 'Scheduled operation not found'
})
class ScheduledOperationResource(Resource): class ScheduledOperationResource(Resource):
@requires_auth @requires_auth
@api.response(200, 'OK', scheduled_operation_model)
@marshal_with_field(Object(scheduled_operation_model)) @marshal_with_field(Object(scheduled_operation_model))
def get(self, id): def get(self, id):
""" """
@ -72,25 +118,40 @@ class ScheduledOperationResource(Resource):
scheduled_operation = ScheduledOperation.query().get(id) scheduled_operation = ScheduledOperation.query().get(id)
if not scheduled_operation: if not scheduled_operation:
return None, 404 api.abort(
404,
error_message='Scheduled operation with id %d not found.' % id
)
return scheduled_operation, 200 return scheduled_operation, 200
@requires_auth @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)) @marshal_with_field(Object(scheduled_operation_model))
def post(self, id): def post(self, id):
""" """
Update a scheduled operation. Update a scheduled operation.
""" """
data = scheduled_operation_parser.parse_args() data = api.payload
assert (id not in data or data.id is None # Check ID consistency.
or data.id == id) 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) scheduled_operation = ScheduledOperation.query().get(id)
if not scheduled_operation: 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. # SQLAlchemy objects ignore __dict__.update() with merge.
for k, v in data.items(): for k, v in data.items():
@ -105,6 +166,8 @@ class ScheduledOperationResource(Resource):
return scheduled_operation, 200 return scheduled_operation, 200
@requires_auth @requires_auth
@api.response(200, 'OK', scheduled_operation_model)
@api.response(409, 'Cannot be deleted')
@marshal_with_field(Object(scheduled_operation_model)) @marshal_with_field(Object(scheduled_operation_model))
def delete(self, id): def delete(self, id):
""" """
@ -113,24 +176,24 @@ class ScheduledOperationResource(Resource):
scheduled_operation = ScheduledOperation.query().get(id) scheduled_operation = ScheduledOperation.query().get(id)
if not scheduled_operation: 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( operations = scheduled_operation.operations.filter(
Operation.confirmed == true() Operation.confirmed == true()
).count() ).count()
if operations: if operations:
return "There are still confirmed operations associated to this \ api.abort(
scheduled operation.", 409 409,
error_message='There are still confirmed operations \
associated to this scheduled operation.')
# Delete unconfirmed operations # Delete unconfirmed operations
operations = scheduled_operation.operations.delete() scheduled_operation.operations.delete()
db.session.delete(scheduled_operation) db.session.delete(scheduled_operation)
return None, 204 return None, 204
api.add_resource(ScheduledOperationListResource, "/scheduled_operation")
api.add_resource(ScheduledOperationResource,
"/scheduled_operation/<int:id>")

View File

@ -20,7 +20,7 @@ import arrow
from functools import wraps from functools import wraps
from flask import request, g 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 from accountant import app
@ -30,8 +30,7 @@ from ..fields import Object
from ..models.users import User from ..models.users import User
from .models import token_model, user_model from .models import user_model, token_model, login_model
from .parsers import login_parser
def load_user_from_token(token): def load_user_from_token(token):
@ -43,10 +42,6 @@ def load_user_from_auth(auth):
return load_user_from_token(token) return load_user_from_token(token)
def authenticate():
return {'error': 'Please login before executing this request.'}, 401
def requires_auth(f): def requires_auth(f):
@wraps(f) @wraps(f)
def wrapped(*args, **data): def wrapped(*args, **data):
@ -60,24 +55,37 @@ def requires_auth(f):
g.user = user g.user = user
return f(*args, **data) return f(*args, **data)
return authenticate() api.abort(
401,
error_message='Please login before executing this request.'
)
return wrapped return wrapped
ns = api.namespace('user', description='User management')
@ns.route('/login')
class LoginResource(Resource): 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): def post(self):
""" """
Login to retrieve authentication token. Login to retrieve authentication token.
""" """
data = login_parser.parse_args() data = api.payload
user = User.query().filter( user = User.query().filter(
User.email == data['email'] User.email == data['email']
).one_or_none() ).one_or_none()
if not user or not user.verify_password(data['password']): 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() token = user.generate_auth_token()
expiration_time = arrow.now().replace( expiration_time = arrow.now().replace(
@ -91,9 +99,14 @@ class LoginResource(Resource):
}, 200 }, 200
@requires_auth @requires_auth
@api.doc(
security='apikey',
responses={
200: ('OK', user_model)
})
@marshal_with_field(Object(user_model)) @marshal_with_field(Object(user_model))
def get(self): def get(self):
"""
Get authenticated user information.
"""
return g.user, 200 return g.user, 200
api.add_resource(LoginResource, "/user/login")

View File

@ -5,7 +5,7 @@ Flask-Assets==0.11
Flask-Bower==1.2.1 Flask-Bower==1.2.1
Flask-Login==0.2.11 Flask-Login==0.2.11
Flask-Migrate==1.5.1 Flask-Migrate==1.5.1
Flask-RESTful==0.3.4 flask-restplus=0.8.6
flask-cors=2.1.2 flask-cors=2.1.2
Flask-Script==2.0.5 Flask-Script==2.0.5
Flask-SQLAlchemy==2.0 Flask-SQLAlchemy==2.0