API v1 financial_transactions endpoints (#627)

This commit is contained in:
wvengen 2020-07-25 14:18:59 +00:00 committed by GitHub
parent 8c8b42c2b2
commit b96ce06d94
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 397 additions and 1 deletions

View file

@ -0,0 +1,24 @@
class Api::V1::FinancialTransactionsController < Api::V1::BaseController
include Concerns::CollectionScope
before_action ->{ doorkeeper_authorize! 'finance:read', 'finance:write' }
def index
render_collection search_scope
end
def show
render json: scope.find(params.require(:id))
end
private
def scope
FinancialTransaction.includes(:user)
end
def ransack_auth_object
:finance
end
end

View file

@ -0,0 +1,21 @@
class Api::V1::User::FinancialTransactionsController < Api::V1::BaseController
include Concerns::CollectionScope
before_action ->{ doorkeeper_authorize! 'finance:user' }
before_action :require_ordergroup
def index
render_collection search_scope
end
def show
render json: scope.find(params.require(:id))
end
private
def scope
current_ordergroup.financial_transactions.includes(:user)
end
end

View file

@ -0,0 +1,58 @@
module Concerns::CollectionScope
extend ActiveSupport::Concern
private
def scope
raise NotImplementedError, 'Please override #scope when you use Concerns::CollectionScope'
end
def default_per_page
20
end
def max_per_page
250
end
def per_page
# allow max_per_page and default_per_page to be nil as well
if params[:per_page]
[params[:per_page].to_i, max_per_page].compact.min
else
default_per_page
end
end
def search_scope
s = scope
s = s.ransack(params[:q], auth_object: ransack_auth_object).result(distinct: true) if params[:q]
s = s.page(params[:page].to_i).per(per_page) if per_page && per_page >= 0
s
end
def render_collection(scope)
render json: scope, meta: collection_meta(scope)
end
def collection_meta(scope, extra = {})
return unless scope.respond_to?(:total_count) && per_page
{
page: params[:page].to_i,
per_page: per_page,
total_pages: (scope.total_count / [1, per_page].max).ceil,
total_count: scope.total_count
}.merge(extra)
end
# By default, there are no special ransack search scope authentications.
# Controllers can override this to return something else and customize a model's
# +ransackable_attributes+ and +ransackable_associations+ to allow searching on more
# parameters in one controller than another (e.g. to protect searches that are scoped
# to a user, while still allowing all search parameters for another endpoint).
def ransack_auth_object
nil
end
end

View file

@ -22,6 +22,17 @@ class FinancialTransaction < ApplicationRecord
initialize_financial_transaction_type initialize_financial_transaction_type
end end
# @todo remove alias (and rename created_on to created_at below) after #575
ransack_alias :created_at, :created_on
def self.ransackable_attributes(auth_object = nil)
%w(id amount note created_on user_id)
end
def self.ransackable_associations(auth_object = nil)
%w() # none, and certainly not user until we've secured that more
end
# Use this save method instead of simple save and after callback # Use this save method instead of simple save and after callback
def add_transaction! def add_transaction!
ordergroup.add_financial_transaction! amount, note, user, financial_transaction_type ordergroup.add_financial_transaction! amount, note, user, financial_transaction_type
@ -43,6 +54,11 @@ class FinancialTransaction < ApplicationRecord
reverts.present? || reverted_by.present? reverts.present? || reverted_by.present?
end end
# @todo rename in model, see #575
def created_at
created_on
end
protected protected
def initialize_financial_transaction_type def initialize_financial_transaction_type

View file

@ -0,0 +1,13 @@
class FinancialTransactionSerializer < ActiveModel::Serializer
include ApplicationHelper
attributes :id, :user_id, :user_name, :amount, :note, :created_at
def user_name
show_user object.user
end
def amount
object.amount.to_f
end
end

View file

@ -49,9 +49,10 @@ Doorkeeper.configure do
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
# default is a collection of read-only scopes # default is a collection of read-only scopes
default_scopes 'config:user', 'user:read' default_scopes 'config:user', 'finance:user', 'user:read'
optional_scopes 'config:read', 'config:write', optional_scopes 'config:read', 'config:write',
'finance:read', 'finance:write',
'user:write', 'user:write',
'offline_access' 'offline_access'

View file

@ -254,7 +254,10 @@ Foodsoft::Application.routes.draw do
namespace :user do namespace :user do
root to: 'users#show' root to: 'users#show'
resources :financial_transactions, only: [:index, :show]
end end
resources :financial_transactions, only: [:index, :show]
end end
end end

View file

@ -15,6 +15,11 @@ info:
version is recent enough). version is recent enough).
This API description points to the default development url with the default This API description points to the default development url with the default
Foodsoft scope - that would be [http://localhost:3000/f](http://localhost:3000/f). Foodsoft scope - that would be [http://localhost:3000/f](http://localhost:3000/f).
You may find the search parameters for index endpoints lacking. They are not
documented here, because there are too many combinations. For now, you'll need
to resort to [Ransack](https://github.com/activerecord-hackery/ransack) and
looking at Foodsoft's `ransackable_*` model class methods.
externalDocs: externalDocs:
description: General Foodsoft API documentation description: General Foodsoft API documentation
url: https://github.com/foodcoops/foodsoft/blob/master/doc/API.md url: https://github.com/foodcoops/foodsoft/blob/master/doc/API.md
@ -52,6 +57,129 @@ paths:
$ref: '#/definitions/Error403' $ref: '#/definitions/Error403'
security: security:
- foodsoft_auth: ['user:read', 'user:write'] - foodsoft_auth: ['user:read', 'user:write']
/user/financial_transactions:
get:
summary: financial transactions of the member's ordergroup
tags:
- 1. User
- 6. FinancialTransaction
parameters:
- $ref: '#/parameters/page'
- $ref: '#/parameters/per_page'
responses:
200:
description: success
schema:
type: object
properties:
financial_transactions:
type: array
items:
$ref: '#/definitions/FinancialTransaction'
meta:
$ref: '#/definitions/Meta'
401:
description: not logged-in
schema:
$ref: '#/definitions/Error401'
403:
description: user has no ordergroup or missing scope
schema:
$ref: '#/definitions/Error403'
security:
- foodsoft_auth: ['finance:user']
/user/financial_transactions/{id}:
parameters:
- $ref: '#/parameters/idInUrl'
get:
summary: find financial transaction by id
tags:
- 1. User
- 6. FinancialTransaction
responses:
200:
description: success
schema:
type: object
properties:
financial_transaction:
$ref: '#/definitions/FinancialTransaction'
401:
description: not logged-in
schema:
$ref: '#/definitions/Error401'
403:
description: user has no ordergroup or missing scope
schema:
$ref: '#/definitions/Error403'
404:
description: not found
schema:
$ref: '#/definitions/Error404'
security:
- foodsoft_auth: ['finance:user']
/financial_transactions:
get:
summary: financial transactions
tags:
- 6. FinancialTransaction
parameters:
- $ref: '#/parameters/page'
- $ref: '#/parameters/per_page'
responses:
200:
description: success
schema:
type: object
properties:
financial_transactions:
type: array
items:
$ref: '#/definitions/FinancialTransaction'
meta:
$ref: '#/definitions/Meta'
401:
description: not logged-in
schema:
$ref: '#/definitions/Error401'
403:
description: missing scope or no permission
schema:
$ref: '#/definitions/Error403'
security:
- foodsoft_auth: ['finance:read', 'finance:write']
/financial_transactions/{id}:
parameters:
- $ref: '#/parameters/idInUrl'
get:
summary: find financial transaction by id
tags:
- 6. FinancialTransaction
responses:
200:
description: success
schema:
type: object
properties:
financial_transaction:
$ref: '#/definitions/FinancialTransaction'
401:
description: not logged-in
schema:
$ref: '#/definitions/Error401'
403:
description: missing scope or no permission
schema:
$ref: '#/definitions/Error403'
404:
description: not found
schema:
$ref: '#/definitions/Error404'
security:
- foodsoft_auth: ['finance:read', 'finance:write']
/config: /config:
get: get:
summary: configuration variables summary: configuration variables
@ -92,6 +220,31 @@ paths:
security: security:
- foodsoft_auth: [] - foodsoft_auth: []
parameters:
# url parameters
idInUrl:
name: id
type: integer
in: path
minimum: 1
required: true
# query parameters
page:
name: page
type: integer
in: query
description: page number
minimum: 0
default: 0
per_page:
name: per_page
type: integer
in: query
description: items per page
minimum: 0
default: 20
definitions: definitions:
# models # models
User: User:
@ -109,6 +262,30 @@ definitions:
type: string type: string
description: language code description: language code
required: ['id', 'name', 'email'] required: ['id', 'name', 'email']
FinancialTransaction:
type: object
properties:
id:
type: integer
user_id:
type: ['integer', 'null']
description: id of user who entered the transaction (may be <tt>null</tt> for deleted users or 0 for a system user)
user_name:
type: ['string', 'null']
description: name of user who entered the transaction (may be <tt>null</tt> or empty string for deleted users or system users)
amount:
type: number
description: amount credited (negative for a debit transaction)
note:
type: string
description: note entered with the transaction
created_at:
type: string
format: date-time
description: when the transaction was entered
required: ['id', 'user_id', 'user_name', 'amount', 'note', 'created_at']
Navigation: Navigation:
type: array type: array
items: items:
@ -125,6 +302,24 @@ definitions:
required: ['name'] required: ['name']
minProperties: 2 # name+url or name+items minProperties: 2 # name+url or name+items
# collection meta object in root of a response
Meta:
type: object
properties:
page:
type: integer
description: page number of the returned collection
per_page:
type: integer
description: number of items per page
total_pages:
type: integer
description: total number of pages
total_count:
type: integer
description: total number of items in the collection
required: ['page', 'per_page', 'total_pages', 'total_count']
Error: Error:
type: object type: object
properties: properties:
@ -168,6 +363,9 @@ securityDefinitions:
config:user: reading Foodsoft configuration for regular users config:user: reading Foodsoft configuration for regular users
config:read: reading Foodsoft configuration values config:read: reading Foodsoft configuration values
config:write: reading and updating Foodsoft configuration values config:write: reading and updating Foodsoft configuration values
finance:user: accessing your own financial transactions
finance:read: reading all financial transactions
finance:write: reading and creating financial transactions
user:read: reading your own user profile user:read: reading your own user profile
user:write: reading and updating your own user profile user:write: reading and updating your own user profile
offline_access: retain access after user has logged out offline_access: retain access after user has logged out

View file

@ -27,6 +27,32 @@ describe 'API v1', type: :apivore, order: :defined do
it_handles_invalid_token_and_scope(:get, '/user') it_handles_invalid_token_and_scope(:get, '/user')
end end
context 'financial_transactions' do
let(:api_scopes) { ['finance:user'] }
let(:other_user) { create :user, :ordergroup }
let!(:other_ft_1) { create :financial_transaction, ordergroup: other_user.ordergroup }
context 'without ordergroup' do
it { is_expected.to validate(:get, '/user/financial_transactions', 403, api_auth) }
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 403, api_auth({'id' => other_ft_1.id})) }
end
context 'with ordergroup' do
let(:user) { create :user, :ordergroup }
let!(:ft_1) { create :financial_transaction, ordergroup: user.ordergroup }
let!(:ft_2) { create :financial_transaction, ordergroup: user.ordergroup }
let!(:ft_3) { create :financial_transaction, ordergroup: user.ordergroup }
it { is_expected.to validate(:get, '/user/financial_transactions', 200, api_auth) }
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 200, api_auth({'id' => ft_2.id})) }
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 404, api_auth({'id' => other_ft_1.id})) }
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 404, api_auth({'id' => FinancialTransaction.last.id + 1})) }
it_handles_invalid_token_and_scope(:get, '/user/financial_transactions')
it_handles_invalid_token_and_scope(:get, '/user/financial_transactions/{id}', ->{ api_auth('id' => ft_2.id) })
end
end
context 'config' do context 'config' do
let(:api_scopes) { ['config:user'] } let(:api_scopes) { ['config:user'] }
@ -42,6 +68,27 @@ describe 'API v1', type: :apivore, order: :defined do
it_handles_invalid_token(:get, '/navigation') it_handles_invalid_token(:get, '/navigation')
end end
context 'financial_transactions' do
let(:api_scopes) { ['finance:read'] }
let(:user) { create(:user, :role_finance) }
let(:other_user) { create :user, :ordergroup }
let!(:ft_1) { create :financial_transaction, ordergroup: other_user.ordergroup }
let!(:ft_2) { create :financial_transaction, ordergroup: other_user.ordergroup }
it { is_expected.to validate(:get, '/financial_transactions', 200, api_auth) }
it { is_expected.to validate(:get, '/financial_transactions/{id}', 200, api_auth({'id' => ft_2.id})) }
it { is_expected.to validate(:get, '/financial_transactions/{id}', 404, api_auth({'id' => FinancialTransaction.last.id + 1})) }
context 'without role_finance' do
let(:user) { create(:user) }
it { is_expected.to validate(:get, '/financial_transactions', 403, api_auth) }
it { is_expected.to validate(:get, '/financial_transactions/{id}', 403, api_auth({'id' => ft_2.id})) }
end
it_handles_invalid_token_and_scope(:get, '/financial_transactions')
it_handles_invalid_token_and_scope(:get, '/financial_transactions/{id}', ->{ api_auth({'id' => ft_2.id}) })
end
end end
# needs to be last context so it is always run at the end # needs to be last context so it is always run at the end

View file

@ -0,0 +1,15 @@
require 'factory_bot'
FactoryBot.define do
factory :financial_transaction do
user
ordergroup
amount { rand(-99_999.00..99_999.00) }
note { Faker::Lorem.sentence }
# This builds a new type and class by default, while for normal financial
# transactions we'd use the default. This, however, is the easiest way to
# get the factory going. If you want equal types, specify it explicitly.
financial_transaction_type
end
end