From ed9192c47f4901e5e8738d75dba45b1af884eb03 Mon Sep 17 00:00:00 2001 From: wvengen Date: Sat, 13 Oct 2018 16:21:37 +0200 Subject: [PATCH] API v1 order_articles endpoints --- .../api/v1/order_articles_controller.rb | 38 ++++++ app/models/article.rb | 14 ++ app/models/order_article.rb | 8 ++ app/models/ordergroup.rb | 3 +- app/models/stock_article.rb | 10 ++ app/serializers/article_serializer.rb | 9 ++ app/serializers/order_article_serializer.rb | 10 ++ app/serializers/stock_article_serializer.rb | 7 + config/routes.rb | 1 + doc/swagger.v1.yml | 128 ++++++++++++++++++ spec/api/v1/order_articles_spec.rb | 57 ++++++++ spec/api/v1/swagger_spec.rb | 15 ++ spec/factories/article.rb | 13 +- spec/support/api_oauth.rb | 14 ++ 14 files changed, 323 insertions(+), 4 deletions(-) create mode 100644 app/controllers/api/v1/order_articles_controller.rb create mode 100644 app/serializers/article_serializer.rb create mode 100644 app/serializers/order_article_serializer.rb create mode 100644 app/serializers/stock_article_serializer.rb create mode 100644 spec/api/v1/order_articles_spec.rb create mode 100644 spec/support/api_oauth.rb diff --git a/app/controllers/api/v1/order_articles_controller.rb b/app/controllers/api/v1/order_articles_controller.rb new file mode 100644 index 00000000..02874a69 --- /dev/null +++ b/app/controllers/api/v1/order_articles_controller.rb @@ -0,0 +1,38 @@ +class Api::V1::OrderArticlesController < Api::V1::BaseController + include Concerns::CollectionScope + + before_action ->{ doorkeeper_authorize! 'orders:read', 'orders:write' } + + def index + render_collection search_scope + end + + def show + render json: scope.find(params.require(:id)) + end + + private + + def scope + OrderArticle.includes(:article_price, article: :supplier) + end + + def search_scope + merge_ordered_scope(super, params.fetch(:q, {})[:ordered]) + end + + def merge_ordered_scope(scope, ordered) + if ordered.blank? + scope + elsif ordered == 'member' && current_ordergroup + scope.joins(:group_order_articles).merge(current_ordergroup.group_order_articles) + elsif ordered == 'all' + table = scope.arel_table + scope.where(table[:quantity].gt(0).or(table[:tolerance].gt(0))) + elsif ordered == 'supplier' + scope.ordered + else + scope.none # as a hint that it's an invalid value + end + end +end diff --git a/app/models/article.rb b/app/models/article.rb index 8486290b..64c0dd34 100644 --- a/app/models/article.rb +++ b/app/models/article.rb @@ -43,6 +43,12 @@ class Article < ApplicationRecord # @!attribute article_prices # @return [Array] Price history (current price first). has_many :article_prices, -> { order("created_at DESC") } + # @!attribute order_articles + # @return [Array] Order articles for this article. + has_many :order_articles + # @!attribute order + # @return [Array] Orders this article appears in. + has_many :orders, through: :order_articles # Replace numeric seperator with database format localize_input_of :price, :tax, :deposit @@ -68,6 +74,14 @@ class Article < ApplicationRecord before_save :update_price_history before_destroy :check_article_in_use + def self.ransackable_attributes(auth_object = nil) + %w(id name supplier_id article_category_id unit note manufacturer origin unit_quantity order_number) + end + + def self.ransackable_associations(auth_object = nil) + %w(article_category supplier order_articles orders) + end + # Returns true if article has been updated at least 2 days ago def recently_updated updated_at > 2.days.ago diff --git a/app/models/order_article.rb b/app/models/order_article.rb index a238e7a0..2b16f08b 100644 --- a/app/models/order_article.rb +++ b/app/models/order_article.rb @@ -20,6 +20,14 @@ class OrderArticle < ApplicationRecord before_create :init_from_balancing after_destroy :update_ordergroup_prices + def self.ransackable_attributes(auth_object = nil) + %w(id order_id article_id quantity tolerance units_to_order) + end + + def self.ransackable_associations(auth_object = nil) + %w(order article) + end + # This method returns either the ArticlePrice or the Article # The first will be set, when the the order is finished def price diff --git a/app/models/ordergroup.rb b/app/models/ordergroup.rb index 2a62caf5..e65bd1a0 100644 --- a/app/models/ordergroup.rb +++ b/app/models/ordergroup.rb @@ -13,7 +13,8 @@ class Ordergroup < Group has_many :financial_transactions has_many :group_orders - has_many :orders, :through => :group_orders + has_many :orders, through: :group_orders + has_many :group_order_articles, through: :group_orders validates_numericality_of :account_balance, :message => I18n.t('ordergroups.model.invalid_balance') validate :uniqueness_of_name, :uniqueness_of_members diff --git a/app/models/stock_article.rb b/app/models/stock_article.rb index 52b363bb..ba3b4d1b 100644 --- a/app/models/stock_article.rb +++ b/app/models/stock_article.rb @@ -9,6 +9,16 @@ class StockArticle < Article before_destroy :check_quantity + ransack_alias :quantity_available, :quantity # in-line with {StockArticleSerializer} + + def self.ransackable_attributes(auth_object = nil) + super(auth_object) - %w(supplier_id) + %w(quantity) + end + + def self.ransackable_associations(auth_object = nil) + super(auth_object) - %w(supplier) + end + # Update the quantity of items in stock def update_quantity! update_attribute :quantity, stock_changes.collect(&:quantity).sum diff --git a/app/serializers/article_serializer.rb b/app/serializers/article_serializer.rb new file mode 100644 index 00000000..d22119ec --- /dev/null +++ b/app/serializers/article_serializer.rb @@ -0,0 +1,9 @@ +class ArticleSerializer < ActiveModel::Serializer + attributes :id, :name + attributes :supplier_id, :supplier_name + attributes :unit, :unit_quantity, :note, :manufacturer, :origin, :article_category_id + + def supplier_name + object.supplier.try(:name) + end +end diff --git a/app/serializers/order_article_serializer.rb b/app/serializers/order_article_serializer.rb new file mode 100644 index 00000000..10de058f --- /dev/null +++ b/app/serializers/order_article_serializer.rb @@ -0,0 +1,10 @@ +class OrderArticleSerializer < ActiveModel::Serializer + attributes :id, :order_id, :price + attributes :quantity, :tolerance, :units_to_order + + has_one :article + + def price + object.price.fc_price.to_f + end +end diff --git a/app/serializers/stock_article_serializer.rb b/app/serializers/stock_article_serializer.rb new file mode 100644 index 00000000..9bf43afc --- /dev/null +++ b/app/serializers/stock_article_serializer.rb @@ -0,0 +1,7 @@ +class StockArticleSerializer < ArticleSerializer + attribute :quantity_available + + def quantity_available + object.quantity + end +end diff --git a/config/routes.rb b/config/routes.rb index 332f99d2..88391ada 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -272,6 +272,7 @@ Rails.application.routes.draw do resources :financial_transaction_types, only: [:index, :show] resources :financial_transactions, only: [:index, :show] resources :orders, only: [:index, :show] + resources :order_articles, only: [:index, :show] end end diff --git a/doc/swagger.v1.yml b/doc/swagger.v1.yml index 7f44faa0..ca2278b5 100644 --- a/doc/swagger.v1.yml +++ b/doc/swagger.v1.yml @@ -238,6 +238,66 @@ paths: $ref: '#/definitions/Error404' security: - foodsoft_auth: ['orders:read', 'orders:write'] + /order_articles: + get: + summary: order articles + tags: + - 2. Order + parameters: + - $ref: '#/parameters/page' + - $ref: '#/parameters/per_page' + - $ref: '#/parameters/q_ordered' + responses: + 200: + description: success + schema: + type: object + properties: + order_articles: + type: array + items: + $ref: '#/definitions/OrderArticle' + 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: ['group_orders:user'] + /order_articles/{id}: + parameters: + - $ref: '#/parameters/idInUrl' + get: + summary: find order article by id + tags: + - 2. Order + responses: + 200: + description: success + schema: + type: object + properties: + order_article: + $ref: '#/definitions/OrderArticle' + 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: ['orders:read', 'orders:write'] /financial_transaction_classes: get: @@ -410,6 +470,14 @@ parameters: minimum: 0 default: 20 + # non-ransack query parameters + q_ordered: + name: q[ordered] + type: string + in: query + description: "'member' show articles ordered by the user's ordergroup, 'all' by all members, and 'supplier' ordered at the supplier" + enum: ['member', 'all', 'supplier'] + definitions: # models User: @@ -526,6 +594,66 @@ definitions: type: boolean description: if the order is currently in the boxfill phase or not + Article: + type: object + properties: + id: + type: integer + name: + type: string + supplier_id: + type: integer + description: id of supplier, or 0 for stock articles + supplier_name: + type: ['string', 'null'] + description: name of the supplier, or null for stock articles + unit: + type: string + description: amount of each unit, e.g. "100 g" or "kg" + unit_quantity: + type: integer + description: units can only be ordered from the supplier in multiples of unit_quantity + note: + type: ['string', 'null'] + description: generic note + manufacturer: + type: ['string', 'null'] + description: manufacturer + origin: + type: ['string', 'null'] + description: origin, preferably (starting with a) 2-letter ISO country code + article_category_id: + type: integer + description: id of article category + quantity_available: + type: integer + description: number of units available (only present on stock articles) + required: ['id', 'name', 'supplier_id', 'supplier_name', 'unit', 'unit_quantity', 'note', 'manufacturer', 'origin', 'article_category_id'] + + OrderArticle: + type: object + properties: + id: + type: integer + order_id: + type: integer + description: id of order this order article belongs to + price: + type: number + format: float + description: foodcoop price + quantity: + type: integer + description: number of units ordered by members + tolerance: + type: integer + description: number of extra units that members are willing to buy to fill a box + units_to_order: + type: integer + description: number of units to order from the supplier + article: + $ref: '#/definitions/Article' + Navigation: type: array items: diff --git a/spec/api/v1/order_articles_spec.rb b/spec/api/v1/order_articles_spec.rb new file mode 100644 index 00000000..62ab60bf --- /dev/null +++ b/spec/api/v1/order_articles_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +# Most routes are tested in the swagger_spec, this tests (non-ransack) parameters. +describe Api::V1::OrderArticlesController, type: :controller do + include ApiOAuth + let(:api_scopes) { ['orders:read'] } + + let(:json_order_articles) { json_response['order_articles'] } + let(:json_order_article_ids) { json_order_articles.map {|joa| joa["id"] } } + + describe "GET :index" do + context "with param q[ordered]" do + let(:order) { create(:order, article_count: 4) } + let(:order_articles) { order.order_articles } + before do + order_articles[0].update_attributes! quantity: 0, tolerance: 0, units_to_order: 0 + order_articles[1].update_attributes! quantity: 1, tolerance: 0, units_to_order: 0 + order_articles[2].update_attributes! quantity: 0, tolerance: 1, units_to_order: 0 + order_articles[3].update_attributes! quantity: 0, tolerance: 0, units_to_order: 1 + end + + it "(unset)" do + get :index, params: { foodcoop: 'f' } + expect(json_order_articles.count).to eq 4 + end + + it "all" do + get :index, params: { foodcoop: 'f', q: { ordered: 'all' } } + expect(json_order_article_ids).to match_array order_articles[1..2].map(&:id) + end + + it "supplier" do + get :index, params: { foodcoop: 'f', q: { ordered: 'supplier' } } + expect(json_order_article_ids).to match_array [order_articles[3].id] + end + + it "member" do + get :index, params: { foodcoop: 'f', q: { ordered: 'member' } } + expect(json_order_articles.count).to eq 0 + end + + context "when ordered by user" do + let(:user) { create(:user, :ordergroup) } + let(:go) { create(:group_order, order: order, ordergroup: user.ordergroup) } + before do + create(:group_order_article, group_order: go, order_article: order_articles[1], quantity: 1) + create(:group_order_article, group_order: go, order_article: order_articles[2], tolerance: 0) + end + + it "member" do + get :index, params: { foodcoop: 'f', q: { ordered: 'member' } } + expect(json_order_article_ids).to match_array order_articles[1..2].map(&:id) + end + end + end + end +end diff --git a/spec/api/v1/swagger_spec.rb b/spec/api/v1/swagger_spec.rb index 4a0dc370..74404756 100644 --- a/spec/api/v1/swagger_spec.rb +++ b/spec/api/v1/swagger_spec.rb @@ -125,6 +125,21 @@ describe 'API v1', type: :apivore, order: :defined do it_handles_invalid_token_and_scope(:get, '/orders') it_handles_invalid_token_and_scope(:get, '/orders/{id}', ->{ api_auth({'id' => order.id}) }) end + + context 'order_articles' do + let(:api_scopes) { ['orders:read'] } + let!(:order_article) { create(:order, article_count: 1).order_articles.first } + let!(:stock_article) { create(:stock_article) } + let!(:stock_order_article) { create(:stock_order, article_ids: [stock_article.id]).order_articles.first } + + it { is_expected.to validate(:get, '/order_articles', 200, api_auth) } + it { is_expected.to validate(:get, '/order_articles/{id}', 200, api_auth({'id' => order_article.id})) } + it { is_expected.to validate(:get, '/order_articles/{id}', 200, api_auth({'id' => stock_order_article.id})) } + it { is_expected.to validate(:get, '/order_articles/{id}', 404, api_auth({'id' => Article.last.id + 1})) } + + it_handles_invalid_token_and_scope(:get, '/order_articles') + it_handles_invalid_token_and_scope(:get, '/order_articles/{id}', ->{ api_auth({'id' => order_article.id}) }) + end end # needs to be last context so it is always run at the end diff --git a/spec/factories/article.rb b/spec/factories/article.rb index 6cf16e31..7112a2b9 100644 --- a/spec/factories/article.rb +++ b/spec/factories/article.rb @@ -11,14 +11,21 @@ FactoryBot.define do factory :article do sequence(:name) { |n| Faker::Lorem.words(number: rand(2..4)).join(' ') + " ##{n}" } - supplier { create :supplier } - article_category { create :article_category } + supplier + article_category end factory :shared_article, class: SharedArticle do sequence(:name) { |n| Faker::Lorem.words(number: rand(2..4)).join(' ') + " s##{n}" } order_number { Faker::Lorem.characters(number: rand(1..12)) } - supplier { create :shared_supplier } + shared_supplier + end + + factory :stock_article, class: StockArticle do + sequence(:name) { |n| Faker::Lorem.words(number: rand(2..4)).join(' ') + " ##{n}" } + unit_quantity { 1 } + supplier + article_category end end diff --git a/spec/support/api_oauth.rb b/spec/support/api_oauth.rb new file mode 100644 index 00000000..8cf283f4 --- /dev/null +++ b/spec/support/api_oauth.rb @@ -0,0 +1,14 @@ +# Dummy OAuth implementation with +current_user+ and scopes +module ApiOAuth + extend ActiveSupport::Concern + + included do + let(:user) { build(:user) } + let(:api_scopes) { [] } # empty scopes for stricter testing (in reality this would be default_scopes) + let(:api_access_token) { double(:acceptable? => true, :accessible? => true, scopes: api_scopes) } + before { allow(controller).to receive(:doorkeeper_token) { api_access_token } } + before { allow(controller).to receive(:current_user) { user } } + + let(:json_response) { JSON.parse(response.body) } + end +end