diff --git a/app/blueprints/agreements/iaa_blueprint.rb b/app/blueprints/agreements/iaa_blueprint.rb index e0d38c73f26..b5359a07917 100644 --- a/app/blueprints/agreements/iaa_blueprint.rb +++ b/app/blueprints/agreements/iaa_blueprint.rb @@ -15,5 +15,7 @@ class IaaBlueprint < Blueprinter::Base field :order_end_date, datetime_format: '%Y-%m-%d' field :order_estimated_amount field :order_status + field :ial2_users + field :authentications end end diff --git a/app/models/agreements/iaa.rb b/app/models/agreements/iaa.rb index 18ee19fe381..28673013775 100644 --- a/app/models/agreements/iaa.rb +++ b/app/models/agreements/iaa.rb @@ -4,12 +4,21 @@ class Iaa include ActiveModel::Model attr_accessor :gtc, :order + attr_writer :ial2_users, :authentications delegate :gtc_number, to: :gtc delegate :order_number, to: :order delegate :mod_number, :start_date, :end_date, :estimated_amount, to: :gtc, prefix: true delegate :mod_number, :start_date, :end_date, :estimated_amount, to: :order, prefix: true + def ial2_users + @ial2_users || 0 + end + + def authentications + @authentications || {} + end + def iaa_number "#{gtc.gtc_number}-#{'%04d' % order.order_number}-#{'%04d' % order.mod_number}" end diff --git a/app/models/agreements/iaa_order.rb b/app/models/agreements/iaa_order.rb index 8bfb4a51e1b..b219aa987f4 100644 --- a/app/models/agreements/iaa_order.rb +++ b/app/models/agreements/iaa_order.rb @@ -25,4 +25,19 @@ class Agreements::IaaOrder < ApplicationRecord def partner_status iaa_status.partner_name end + + def in_pop?(date) + raise ArgumentError unless date.respond_to?(:strftime) + return false if pop_range.blank? + + pop_range.include?(date.to_date) + end + + private + + def pop_range + return unless start_date.present? && end_date.present? + + start_date..end_date + end end diff --git a/app/services/agreements/db/iaas_by_agency.rb b/app/services/agreements/db/iaas_by_agency.rb index 44147d6ea2f..74bba23969c 100644 --- a/app/services/agreements/db/iaas_by_agency.rb +++ b/app/services/agreements/db/iaas_by_agency.rb @@ -3,7 +3,11 @@ module Db class IaasByAgency def self.call IaaGtc. - includes(:iaa_status, partner_account: :agency, iaa_orders: :iaa_status). + includes( + :iaa_status, + partner_account: :agency, + iaa_orders: %i[iaa_status integrations], + ). group_by { |gtc| gtc.partner_account.agency.abbreviation }. transform_values do |gtcs| gtcs.map do |gtc| diff --git a/app/services/agreements/db/sp_return_log_scan.rb b/app/services/agreements/db/sp_return_log_scan.rb new file mode 100644 index 00000000000..632c62acfeb --- /dev/null +++ b/app/services/agreements/db/sp_return_log_scan.rb @@ -0,0 +1,15 @@ +module Agreements + module Db + class SpReturnLogScan + def self.call + SpReturnLog. + select(:id, :issuer, :ial, :user_id, :returned_at). + find_in_batches(batch_size: 10_000) do |batch| + batch.each do |return_log| + yield return_log + end + end + end + end + end +end diff --git a/app/services/agreements/iaa_usage.rb b/app/services/agreements/iaa_usage.rb new file mode 100644 index 00000000000..9e27f1fb453 --- /dev/null +++ b/app/services/agreements/iaa_usage.rb @@ -0,0 +1,30 @@ +module Agreements + class IaaUsage + attr_reader :authentications, :ial2_users + + def initialize(order:) + @order = order + @issuers = order.integrations.pluck(:issuer) + @authentications = Hash.new(0) + @ial2_users = Set.new + end + + # return self so that it can be used in #tranform_values! + def count(return_log) + issuer = return_log.issuer + return self unless issuers.include?(issuer) && order.in_pop?(return_log.returned_at) + + @authentications[issuer] += 1 + + return self unless return_log.ial == 2 + + @ial2_users.add(return_log.user_id) + + self + end + + private + + attr_reader :order, :issuers + end +end diff --git a/app/services/agreements/reports/partner_api_report.rb b/app/services/agreements/reports/partner_api_report.rb index 3831c9fedaa..29f1f0a6205 100644 --- a/app/services/agreements/reports/partner_api_report.rb +++ b/app/services/agreements/reports/partner_api_report.rb @@ -6,22 +6,38 @@ def run collect_account_data collect_iaa_data + collect_usage_data upload_json_files true end private - attr_reader :accounts_by_agency, :agencies, :iaas_by_agency + attr_reader :accounts_by_agency, :agencies, :iaas_by_agency, :usage_summary def collect_account_data - @accounts_by_agency = Agreements::Db::AccountsByAgency.call + @accounts_by_agency = Db::AccountsByAgency.call @agencies = accounts_by_agency.keys @accounts_by_agency = accounts_by_agency.transform_keys(&:abbreviation) end def collect_iaa_data - @iaas_by_agency = Agreements::Db::IaasByAgency.call + @iaas_by_agency = Db::IaasByAgency.call + end + + def collect_usage_data + all_iaas = iaas_by_agency.values.flatten + @usage_summary = UsageSummarizer.call(iaas: all_iaas) + + @iaas_by_agency.transform_values! do |iaas| + iaas.each do |iaa| + usage = usage_summary[:iaas][iaa.iaa_number] + next if usage.blank? + + iaa.ial2_users = usage.ial2_users.size + iaa.authentications = usage.authentications + end + end end def upload_json_files diff --git a/app/services/agreements/usage_summarizer.rb b/app/services/agreements/usage_summarizer.rb new file mode 100644 index 00000000000..82fa9fd47b3 --- /dev/null +++ b/app/services/agreements/usage_summarizer.rb @@ -0,0 +1,40 @@ +module Agreements + class UsageSummarizer + def self.call(**args) + new(**args).call + end + + def initialize(iaas:) + @iaas = iaas + @iaas_by_issuer = map_iaas_to_issuers + @usage = { + iaas: empty_iaa_usage_hash, + } + end + + def call + Db::SpReturnLogScan.call do |return_log| + @usage[:iaas].transform_values! { |iaa_usage| iaa_usage.count(return_log) } + end + + usage + end + + private + + attr_reader :iaas, :iaas_by_issuer, :usage + + def map_iaas_to_issuers + iaas.each_with_object(Hash.new([])) do |iaa, hash| + issuers = iaa.order.integrations.map(&:issuer) + issuers.each { |issuer| hash[issuer] << iaa } + end + end + + def empty_iaa_usage_hash + iaas.each_with_object({}) do |iaa, hash| + hash[iaa.iaa_number] = IaaUsage.new(order: iaa.order) + end + end + end +end diff --git a/spec/blueprints/agreements/iaa_blueprint_spec.rb b/spec/blueprints/agreements/iaa_blueprint_spec.rb index b7f9baa379e..81796a2a08d 100644 --- a/spec/blueprints/agreements/iaa_blueprint_spec.rb +++ b/spec/blueprints/agreements/iaa_blueprint_spec.rb @@ -31,6 +31,11 @@ Agreements::Iaa.new( gtc: gtc, order: order, + ial2_users: 10, + authentications: { + 'issuer1' => 100, + 'issuer2' => 1_000, + }, ) end let(:expected) do @@ -51,6 +56,11 @@ order_end_date: '2021-12-31', order_estimated_amount: '20000.53', order_status: 'active', + ial2_users: 10, + authentications: { + 'issuer1' => 100, + 'issuer2' => 1_000, + }, }, ], }.to_json diff --git a/spec/models/agreements/iaa_order_spec.rb b/spec/models/agreements/iaa_order_spec.rb index 2674e52cb8f..0ad3b378047 100644 --- a/spec/models/agreements/iaa_order_spec.rb +++ b/spec/models/agreements/iaa_order_spec.rb @@ -46,4 +46,32 @@ expect(order.partner_status).to eq('foo') end end + + describe '#in_pop?' do + let(:order) do + build( + :iaa_order, + start_date: Time.zone.today, + end_date: Time.zone.today + 1.week, + ) + end + + it 'raises an argument error if a non-date/datetime is passed in' do + expect{ order.in_pop?('foo') }.to raise_error(ArgumentError) + end + it 'returns false if the start_date is nil' do + order.start_date = nil + expect(order.in_pop?(Time.zone.today + 1.day)).to be false + end + it 'returns false if the end_date is nil' do + order.end_date = nil + expect(order.in_pop?(Time.zone.today + 1.day)).to be false + end + it 'returns false if the date is outside the POP' do + expect(order.in_pop?(Time.zone.today - 1.day)).to be false + end + it 'returns true if the date is within the POP' do + expect(order.in_pop?(Time.zone.today + 1.day)).to be true + end + end end diff --git a/spec/models/agreements/iaa_spec.rb b/spec/models/agreements/iaa_spec.rb index aa115e85f4e..d82f3c8aa66 100644 --- a/spec/models/agreements/iaa_spec.rb +++ b/spec/models/agreements/iaa_spec.rb @@ -16,6 +16,28 @@ it { is_expected.to delegate_method(:end_date).to(:order).with_prefix } it { is_expected.to delegate_method(:estimated_amount).to(:order).with_prefix } + describe 'attributes' do + describe 'ial2_users' do + it 'defaults to zero' do + expect(iaa.ial2_users).to eq(0) + end + it 'can be set to a custom value' do + iaa.ial2_users = 10 + expect(iaa.ial2_users).to eq(10) + end + end + + describe 'authentications' do + it 'defaults to an empty hash' do + expect(iaa.authentications).to eq({}) + end + it 'can be set to a custom value' do + iaa.authentications = { 'issuer1' => 10 } + expect(iaa.authentications).to eq({ 'issuer1' => 10 }) + end + end + end + describe '#iaa_number' do it 'returns the formatted IAA number' do expect(iaa.iaa_number).to eq('LGABC210001-0001-0002') diff --git a/spec/models/sp_return_log_spec.rb b/spec/models/sp_return_log_spec.rb new file mode 100644 index 00000000000..27129fb8553 --- /dev/null +++ b/spec/models/sp_return_log_spec.rb @@ -0,0 +1,9 @@ +require 'rails_helper' + +RSpec.describe SpReturnLog, type: :model do + describe 'associations' do + subject { described_class.new } + + it { is_expected.to belong_to(:user) } + end +end diff --git a/spec/services/agreements/db/sp_return_log_scan_spec.rb b/spec/services/agreements/db/sp_return_log_scan_spec.rb new file mode 100644 index 00000000000..e16c24e71fc --- /dev/null +++ b/spec/services/agreements/db/sp_return_log_scan_spec.rb @@ -0,0 +1,25 @@ +require 'rails_helper' + +RSpec.describe Agreements::Db::SpReturnLogScan do + describe '.call' do + let(:issuers) { %w[issuer1 issuer2 issuer3] } + + before do + issuers.each do |issuer| + create( + :sp_return_log, + issuer: issuer, + requested_at: Time.zone.now, + ial: 1, + ) + end + end + + it 'scans through the sp_return_logs table and yields the block for each record' do + output = [] + described_class.call { |record| output << record.issuer } + + expect(output).to match_array(issuers) + end + end +end diff --git a/spec/services/agreements/iaa_usage_spec.rb b/spec/services/agreements/iaa_usage_spec.rb new file mode 100644 index 00000000000..2f54c9fc0aa --- /dev/null +++ b/spec/services/agreements/iaa_usage_spec.rb @@ -0,0 +1,84 @@ +require 'rails_helper' + +describe Agreements::IaaUsage do + let(:partner_account) { create(:partner_account) } + let(:integration) { create(:integration, partner_account: partner_account) } + let(:order) do + create( + :iaa_order, + iaa_gtc: create(:iaa_gtc, partner_account: partner_account), + start_date: Time.zone.today, + end_date: Time.zone.today + 7.days, + ) + end + let(:usage_obj) { described_class.new(order: order) } + let(:user) { create(:user) } + let(:other_sp) { create(:service_provider) } + + before { order.integrations << integration } + + describe 'defaults' do + it 'returns an empty hash defaulting to zero for authentications' do + expect(usage_obj.authentications).to eq({}) + expect(usage_obj.authentications['foo']).to eq(0) + end + + it 'returns an empty set for ial2_users' do + expect(usage_obj.ial2_users).to eq(Set.new) + end + end + + describe '#count' do + it 'correctly counts IAL1 authentications within the PoP' do + log = + create_return_log(sp: integration, user: user, ial: 1, returned: Time.zone.today + 1.day) + usage_obj.count(log) + + expect(usage_obj.authentications).to eq({ integration.issuer => 1 }) + expect(usage_obj.ial2_users).to eq(Set.new) + end + + it 'correctly counts IAL2 authentications within the PoP' do + log = + create_return_log(sp: integration, user: user, ial: 2, returned: Time.zone.today + 1.day) + usage_obj.count(log) + + expect(usage_obj.authentications).to eq({ integration.issuer => 1 }) + expect(usage_obj.ial2_users).to eq(Set.new([user.id])) + end + + it 'correctly skips return logs outside the PoP' do + log = + create_return_log(sp: integration, user: user, ial: 2, returned: Time.zone.today - 1.day) + + expect { usage_obj.count(log) }.not_to change { usage_obj.authentications } + expect { usage_obj.count(log) }.not_to change { usage_obj.ial2_users } + end + + it 'correctly skipes return logs for other SPs' do + log = + create_return_log(sp: other_sp, user: user, ial: 2, returned: Time.zone.today + 1.day) + + expect { usage_obj.count(log) }.not_to change { usage_obj.authentications } + expect { usage_obj.count(log) }.not_to change { usage_obj.ial2_users } + end + + it 'returns a copy of itself with the updated usage metrics' do + log = + create_return_log(sp: integration, user: user, ial: 2, returned: Time.zone.today + 1.day) + + expect(usage_obj.count(log)).to be_an_instance_of(described_class) + end + end + + def create_return_log(sp:, user:, ial:, returned:) + create( + :sp_return_log, + issuer: sp.issuer, + user_id: user.id, + ial: ial, + requested_at: returned - 1.minute, + returned_at: returned, + ) + end +end diff --git a/spec/services/agreements/usage_summarizer_spec.rb b/spec/services/agreements/usage_summarizer_spec.rb new file mode 100644 index 00000000000..508c9f9d47e --- /dev/null +++ b/spec/services/agreements/usage_summarizer_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +describe Agreements::UsageSummarizer do + let(:gtc) { create(:iaa_gtc, gtc_number: 'LGABC123') } + let(:order1) do + create( + :iaa_order, + iaa_gtc: gtc, + order_number: 1, + start_date: Time.zone.today, + end_date: Time.zone.today + 1.week, + ) + end + let(:order2) do + create( + :iaa_order, + iaa_gtc: gtc, + order_number: 2, + start_date: Time.zone.today + 8.days, + end_date: Time.zone.today + 8.days + 1.week, + ) + end + let(:integration1) { create(:integration, partner_account: gtc.partner_account) } + let(:integration2) { create(:integration, partner_account: gtc.partner_account) } + let(:user1) { create(:user) } + let(:user2) { create(:user) } + let(:iaas) do + [ + Agreements::Iaa.new(gtc: gtc, order: order1), + Agreements::Iaa.new(gtc: gtc, order: order2), + ] + end + + before do + order1.integrations << integration1 + order1.integrations << integration2 + order2.integrations << integration1 + order2.integrations << integration2 + + # order1 + create_sp_log(sp: integration1, user: user1, ial: 1, time: Time.zone.today + 1.day) + create_sp_log(sp: integration1, user: user1, ial: 1, time: Time.zone.today + 2.days) + create_sp_log(sp: integration1, user: user2, ial: 1, time: Time.zone.today + 2.days) + create_sp_log(sp: integration2, user: user1, ial: 2, time: Time.zone.today + 1.day) + create_sp_log(sp: integration2, user: user1, ial: 2, time: Time.zone.today + 2.days) + + # order2 + create_sp_log(sp: integration2, user: user2, ial: 2, time: Time.zone.today + 9.days) + create_sp_log(sp: integration2, user: user1, ial: 2, time: Time.zone.today + 10.days) + end + + describe '.call' do + it 'returns the appropriate usage summary' do + output = described_class.call(iaas: iaas) + + expect(output[:iaas]['LGABC123-0001-0000'].authentications).to \ + eq({ integration1.issuer => 3, integration2.issuer => 2 }) + expect(output[:iaas]['LGABC123-0001-0000'].ial2_users).to \ + eq(Set.new([user1.id])) + expect(output[:iaas]['LGABC123-0002-0000'].authentications).to \ + eq({ integration2.issuer => 2 }) + expect(output[:iaas]['LGABC123-0002-0000'].ial2_users).to \ + eq(Set.new([user1.id, user2.id])) + end + end + + def create_sp_log(sp:, user:, ial:, time:) + SpReturnLog.create!( + issuer: sp.issuer, + user_id: user.id, + ial: ial, + returned_at: time, + requested_at: time - 1.minute, + request_id: SecureRandom.uuid, + ) + end +end