From 0ca6d93e43c12411d29db72b5699f29a131b7905 Mon Sep 17 00:00:00 2001 From: Jeremy Friesen Date: Fri, 5 Apr 2024 13:50:30 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Add=20files=20from=20PALs=20Hyku?= =?UTF-8?q?=20not=20in=20other=20repos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a first pass of the PALs work. There was a lot of head scratching and copying things into Hyku Prime. Together this is a rough process. Related to: - https://github.com/scientist-softserv/palni_palci_knapsack/commit/3595d6febba77918a0e5e6497aa58aaefb8e7d4d --- app/assets/images/loading-progress.gif | Bin 0 -> 10273 bytes app/assets/javascripts/admin_font_select.js | 6 + app/controllers/api/sushi_controller.rb | 52 ++ ...llections_controller_behavior_decorator.rb | 22 + .../admin/workflows_controller_decorator.rb | 17 + .../hyrax/my/works_controller_decorator.rb | 29 + .../workflow_actions_controller_decorator.rb | 20 + app/jobs/create_derivatives_job_decorator.rb | 17 + app/jobs/create_large_derivatives_job.rb | 16 + app/jobs/file_set_index_job.rb | 5 + app/jobs/prune_stale_guest_users_job.rb | 10 + app/jobs/work_index_job.rb | 5 + app/models/file_download_stat_decorator.rb | 25 + app/models/sushi.rb | 497 ++++++++++++++++++ app/models/sushi/error.rb | 123 +++++ app/models/sushi/item_report.rb | 181 +++++++ app/models/sushi/platform_report.rb | 221 ++++++++ app/models/sushi/platform_usage_report.rb | 160 ++++++ app/models/sushi/report_list.rb | 55 ++ app/models/sushi/server_status.rb | 22 + .../hyku/menu_presenter.rb/menu_presenter.rb | 56 ++ app/services/generate_counter_metrics.rb | 40 ++ ...lection_member_search_service_decorator.rb | 22 + .../hyrax/solr_query_service_decorator.rb | 13 + app/services/import_counter_metrics.rb | 92 ++++ .../location_decorator.rb | 39 ++ lib/tasks/counter_metrics.rake | 47 ++ lib/tasks/sidekiq.rake | 13 + lib/tasks/tenants.rake | 56 ++ lib/tasks/workflow_setup.rake | 6 + .../jobs/create_large_derivatives_job_spec.rb | 41 ++ spec/models/sushi/item_report_spec.rb | 251 +++++++++ spec/models/sushi/platform_report_spec.rb | 179 +++++++ .../sushi/platform_usage_report_spec.rb | 57 ++ spec/models/sushi/report_list_spec.rb | 48 ++ spec/models/sushi/server_status_spec.rb | 26 + spec/models/sushi_spec.rb | 277 ++++++++++ spec/requests/api/sushi_spec.rb | 94 ++++ spec/services/import_counter_metrics_spec.rb | 14 + 39 files changed, 2854 insertions(+) create mode 100644 app/assets/images/loading-progress.gif create mode 100644 app/assets/javascripts/admin_font_select.js create mode 100644 app/controllers/api/sushi_controller.rb create mode 100644 app/controllers/concerns/hyrax/collections_controller_behavior_decorator.rb create mode 100644 app/controllers/hyrax/admin/workflows_controller_decorator.rb create mode 100644 app/controllers/hyrax/my/works_controller_decorator.rb create mode 100644 app/controllers/hyrax/workflow_actions_controller_decorator.rb create mode 100644 app/jobs/create_derivatives_job_decorator.rb create mode 100644 app/jobs/create_large_derivatives_job.rb create mode 100644 app/jobs/file_set_index_job.rb create mode 100644 app/jobs/prune_stale_guest_users_job.rb create mode 100644 app/jobs/work_index_job.rb create mode 100644 app/models/file_download_stat_decorator.rb create mode 100644 app/models/sushi.rb create mode 100644 app/models/sushi/error.rb create mode 100644 app/models/sushi/item_report.rb create mode 100644 app/models/sushi/platform_report.rb create mode 100644 app/models/sushi/platform_usage_report.rb create mode 100644 app/models/sushi/report_list.rb create mode 100644 app/models/sushi/server_status.rb create mode 100644 app/presenters/hyku/menu_presenter.rb/menu_presenter.rb create mode 100644 app/services/generate_counter_metrics.rb create mode 100644 app/services/hyrax/collections/collection_member_search_service_decorator.rb create mode 100644 app/services/hyrax/solr_query_service_decorator.rb create mode 100644 app/services/import_counter_metrics.rb create mode 100644 lib/hyrax/controlled_vocabularies/location_decorator.rb create mode 100644 lib/tasks/counter_metrics.rake create mode 100644 lib/tasks/sidekiq.rake create mode 100644 lib/tasks/tenants.rake create mode 100644 lib/tasks/workflow_setup.rake create mode 100644 spec/jobs/create_large_derivatives_job_spec.rb create mode 100644 spec/models/sushi/item_report_spec.rb create mode 100644 spec/models/sushi/platform_report_spec.rb create mode 100644 spec/models/sushi/platform_usage_report_spec.rb create mode 100644 spec/models/sushi/report_list_spec.rb create mode 100644 spec/models/sushi/server_status_spec.rb create mode 100644 spec/models/sushi_spec.rb create mode 100644 spec/requests/api/sushi_spec.rb create mode 100644 spec/services/import_counter_metrics_spec.rb diff --git a/app/assets/images/loading-progress.gif b/app/assets/images/loading-progress.gif new file mode 100644 index 0000000000000000000000000000000000000000..77a5af85af6f598c84e1eac1c42370ce95608c12 GIT binary patch literal 10273 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 PhW~w<;lDr4pzwbH*n3?? literal 0 HcmV?d00001 diff --git a/app/assets/javascripts/admin_font_select.js b/app/assets/javascripts/admin_font_select.js new file mode 100644 index 0000000000..7fa0651089 --- /dev/null +++ b/app/assets/javascripts/admin_font_select.js @@ -0,0 +1,6 @@ +Blacklight.onLoad(function() { + if($("#admin_appearance_body_font").length > 0){ + $("#admin_appearance_body_font").fontselect({lookahead: 20}); + $("#admin_appearance_headline_font").fontselect({lookahead: 20}); + } +}); diff --git a/app/controllers/api/sushi_controller.rb b/app/controllers/api/sushi_controller.rb new file mode 100644 index 0000000000..392aceff82 --- /dev/null +++ b/app/controllers/api/sushi_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module API + class SushiController < ApplicationController + private + + ## + # We have encountered an error in their request, this might be an invalid date or a missing + # required parameter. The end user can adjust the request and try again. + # + # @param [Exception] + def render_sushi_exception(error) + render json: error, status: 422 + end + rescue_from Sushi::Error::Exception, with: :render_sushi_exception + + public + + ## + # needs to include the following filters: begin & end date, item ID + # + # + # When given an item_id parameter, filter the results to only that item_id. + # + # @note We should not need to find the record in ActiveFedora; hopefully we have all we need in + # the stats database. + def item_report + @report = Sushi::ItemReport.new(params, account: current_account) + render json: @report + end + + def platform_report + @report = Sushi::PlatformReport.new(params, account: current_account) + render json: @report + end + + def platform_usage + @report = Sushi::PlatformUsageReport.new(params, account: current_account) + render json: @report + end + + def server_status + @status = Sushi::ServerStatus.new(account: current_account).server_status + render json: @status + end + + def report_list + @report = Sushi::ReportList.new + render json: @report + end + end +end diff --git a/app/controllers/concerns/hyrax/collections_controller_behavior_decorator.rb b/app/controllers/concerns/hyrax/collections_controller_behavior_decorator.rb new file mode 100644 index 0000000000..4faa11c552 --- /dev/null +++ b/app/controllers/concerns/hyrax/collections_controller_behavior_decorator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# OVERRIDE Hyrax v5.0.0 to sort subcollections by title + +module Hyrax + module CollectionsControllerBehaviorDecorator + def load_member_subcollections + super + return if @subcollection_docs.blank? + + @subcollection_docs.sort_by! { |doc| doc.title.first&.downcase } + end + + def show + params[:sort] ||= Hyrax::Collections::CollectionMemberSearchServiceDecorator::DEFAULT_SORT_FIELD + + super + end + end +end + +Hyrax::CollectionsControllerBehavior.prepend Hyrax::CollectionsControllerBehaviorDecorator diff --git a/app/controllers/hyrax/admin/workflows_controller_decorator.rb b/app/controllers/hyrax/admin/workflows_controller_decorator.rb new file mode 100644 index 0000000000..b1a44f0cc8 --- /dev/null +++ b/app/controllers/hyrax/admin/workflows_controller_decorator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# OVERRIDE Hyrax 3.6.0 to redirect back to the review submissions page after approving or rejecting a work +# when the user came from the review submissions page + +module Hyrax + module Admin + module WorkflowsControllerDecorator + def index + super + session[:from_admin_workflows] = true if request.fullpath.include?(admin_workflows_path) + end + end + end +end + +Hyrax::Admin::WorkflowsController.prepend(Hyrax::Admin::WorkflowsControllerDecorator) diff --git a/app/controllers/hyrax/my/works_controller_decorator.rb b/app/controllers/hyrax/my/works_controller_decorator.rb new file mode 100644 index 0000000000..a51885fc95 --- /dev/null +++ b/app/controllers/hyrax/my/works_controller_decorator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# OVERRIDE Hyrax 5.0.0 to add custom sort fields while in the dashboard for works + +module Hyrax + module My + module WorksControllerDecorator + def configure_facets + configure_blacklight do |config| + # clear facets copied from the CatalogController + config.sort_fields.clear + config.add_sort_field "date_uploaded_dtsi desc", label: "date uploaded \u25BC" + config.add_sort_field "date_uploaded_dtsi asc", label: "date uploaded \u25B2" + config.add_sort_field "date_modified_dtsi desc", label: "date modified \u25BC" + config.add_sort_field "date_modified_dtsi asc", label: "date modified \u25B2" + config.add_sort_field "system_create_dtsi desc", label: "date created \u25BC" + config.add_sort_field "system_create_dtsi asc", label: "date created \u25B2" + config.add_sort_field "depositor_ssi asc, title_ssi asc", label: "depositor (A-Z)" + config.add_sort_field "depositor_ssi desc, title_ssi desc", label: "depositor (Z-A)" + config.add_sort_field "creator_ssi asc, title_ssi asc", label: "creator (A-Z)" + config.add_sort_field "creator_ssi desc, title_ssi desc", label: "creator (Z-A)" + end + end + end + end +end + +Hyrax::My::WorksController.singleton_class.send(:prepend, Hyrax::My::WorksControllerDecorator) +Hyrax::My::WorksController.configure_facets diff --git a/app/controllers/hyrax/workflow_actions_controller_decorator.rb b/app/controllers/hyrax/workflow_actions_controller_decorator.rb new file mode 100644 index 0000000000..3ec77e6f5b --- /dev/null +++ b/app/controllers/hyrax/workflow_actions_controller_decorator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# OVERRIDE Hyrax 5.0.0 to redirect back to the review submissions page after approving or rejecting a work +# when the user came from the review submissions page + +module Hyrax + module WorkflowActionsControllerDecorator + private + + def after_update_response + respond_to do |wants| + redirect_path = session[:from_admin_workflows] ? admin_workflows_path : [main_app, curation_concern] + wants.html { redirect_to redirect_path, notice: "The #{curation_concern.class.human_readable_type} has been updated." } + wants.json { render 'hyrax/base/show', status: :ok, location: polymorphic_path([main_app, curation_concern]) } + end + end + end +end + +Hyrax::WorkflowActionsController.prepend(Hyrax::WorkflowActionsControllerDecorator) diff --git a/app/jobs/create_derivatives_job_decorator.rb b/app/jobs/create_derivatives_job_decorator.rb new file mode 100644 index 0000000000..88d66a3ec8 --- /dev/null +++ b/app/jobs/create_derivatives_job_decorator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# OVERRIDE Hyrax v3.6.0 +# @see CreateLargeDerivativesJob +module CreateDerivativesJobDecorator + # OVERRIDE: Divert audio and video derivative + # creation to CreateLargeDerivativesJob. + def perform(file_set, file_id, filepath = nil) + return super if is_a?(CreateLargeDerivativesJob) + return super unless file_set.video? || file_set.audio? + + CreateLargeDerivativesJob.perform_later(*arguments) + true + end +end + +CreateDerivativesJob.prepend(CreateDerivativesJobDecorator) diff --git a/app/jobs/create_large_derivatives_job.rb b/app/jobs/create_large_derivatives_job.rb new file mode 100644 index 0000000000..c068d51520 --- /dev/null +++ b/app/jobs/create_large_derivatives_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# CreateLargeDerivativesJob is intended to be used for resource-intensive derivative +# generation (e.g. video processing). It is functionally similar to CreateDerivativesJob, +# except that it queues jobs in the :auxiliary queue. +# +# The worker responsible for processing jobs in the :auxiliary queue should be +# configured to have more resources dedicated to it, especially CPU. Otherwise, the +# `ffmpeg` commands that this job class eventually triggers could be throttled. +# +# @see CreateDerivativesJobDecorator +# @see Hydra::Derivatives::Processors::Ffmpeg +# @see https://github.com/scientist-softserv/palni-palci/issues/852 +class CreateLargeDerivativesJob < CreateDerivativesJob + queue_as :auxiliary +end diff --git a/app/jobs/file_set_index_job.rb b/app/jobs/file_set_index_job.rb new file mode 100644 index 0000000000..f8ba69ae7f --- /dev/null +++ b/app/jobs/file_set_index_job.rb @@ -0,0 +1,5 @@ +class FileSetIndexJob < Hyrax::ApplicationJob + def perform(file_set) + file_set&.update_index + end +end diff --git a/app/jobs/prune_stale_guest_users_job.rb b/app/jobs/prune_stale_guest_users_job.rb new file mode 100644 index 0000000000..ce47c4df22 --- /dev/null +++ b/app/jobs/prune_stale_guest_users_job.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class PruneStaleGuestUsersJob < ApplicationJob + non_tenant_job + repeat 'every week at 8am' # midnight PST + + def perform + RolesService.prune_stale_guest_users + end +end diff --git a/app/jobs/work_index_job.rb b/app/jobs/work_index_job.rb new file mode 100644 index 0000000000..95710c4239 --- /dev/null +++ b/app/jobs/work_index_job.rb @@ -0,0 +1,5 @@ +class WorkIndexJob < Hyrax::ApplicationJob + def perform(work) + work.update_index + end +end diff --git a/app/models/file_download_stat_decorator.rb b/app/models/file_download_stat_decorator.rb new file mode 100644 index 0000000000..b545d90d28 --- /dev/null +++ b/app/models/file_download_stat_decorator.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# OVERRIDE Hyrax hyrax-v3.5.0 to require Hyrax::Download so the method below doesn't fail + +Hyrax::Download # rubocop:disable Lint/Void + +module FileDownloadStatClass + # Hyrax::Download is sent to Hyrax::Analytics.profile as #hyrax__download + # see Legato::ProfileMethods.method_name_from_klass + def ga_statistics(start_date, file) + profile = Hyrax::Analytics.profile + unless profile + Hyrax.logger.error("Google Analytics profile has not been established. Unable to fetch statistics.") + return [] + end + # OVERRIDE Hyrax hyrax-v3.5.0 + profile.hyrax__download(sort: 'date', + start_date: start_date, + end_date: Date.yesterday, + limit: 10_000) + .for_file(file.id) + end +end + +FileDownloadStat.singleton_class.send(:prepend, FileDownloadStatClass) diff --git a/app/models/sushi.rb b/app/models/sushi.rb new file mode 100644 index 0000000000..23b8476245 --- /dev/null +++ b/app/models/sushi.rb @@ -0,0 +1,497 @@ +# frozen_string_literal:true + +require 'sushi/error' + +module Sushi + mattr_accessor :info + + class << self + ## + # @param dates [Array] + # @raise [Sushi::InvalidParameterValue] when either of the given dates are not formatted correctly. + # + # @return [TrueClass] + # + # @note This is necessary because calling `to_date` on a string in 'MM-YYYY' format will assume that 'MM' is the day, + # and will set the current month as 'MM' instead. + # + # params.fetch(:begin_date) => "06-2023" + # params.fetch(:begin_date).to_date => Wed, 06 Sep 2023 + def validate_date_format(dates = []) + dates.each do |date| + match = date.match(/(^\d{4}-\d{2}$)|(^\d{4}-\d{2}-\d{2}$)/) + raise Sushi::Error::InvalidDateArgumentError.new(data: "The given date of \"#{date}\" is invalid. Please provide a date in YYYY-MM format") unless match + + # rubocop:disable Metrics/LineLength + info << Sushi::Info.new(data: date.to_s, message: "The day of the month is not taken into consideration when providing metrics. The date provided was amended to account for the full month.").as_json if match[2] + # rubocop:enable Metrics/LineLength + end + + true + end + + ## + # @param value [String, #to_date] + # + # @return [Date] + # @raise [Sushi::InvalidParameterValue] when we cannot coerce to a date. + def coerce_to_date(value) + value.to_date + rescue StandardError + begin + # We can't parse the original date, so lets attempt to coerce a "YYYY-MM" string (year-month). + # If it fails, we'll raise an exception on garbage input. + year, month, = value.split('-') + + # We want to set the date to the 1st of the month. + Date.new(year.to_i, month.to_i, 1) + rescue StandardError + raise Sushi::Error::InvalidDateArgumentError.new(data: "Unable to convert \"#{value}\" to a date.") + end + end + + ## + # The first day of the month of the earliest entry in {Hyrax::CounterMetric}, with some caveats. + # + # Namely if we only have one month of data, and that month happens to be the current month. + # + # @param current_date [Date] included as a dependency injection to ease testing. + # + # @return [Date] when we have data in the system + # @raise [Sushi::Error::UsageNotReadyForRequestedDatesError] when we don't have data in the + # system OR we only have data for the current month. + # + # @see {.last_month_available} + # + # @note Ultimately, the goal of these date ranges is for us to inform the consumer of the API + # about the complete months of data that we have. + def first_month_available(current_date: Time.zone.today) + raise Sushi::Error::UsageNotReadyForRequestedDatesError.new(data: "There is no available metrics data available at this time.") unless Hyrax::CounterMetric.any? + + # If, for some reason, we have only partial data for the earliest month, we'll assume that + # we have "all" that month's data. + earliest_entry_beginning_of_month_date = Hyrax::CounterMetric.order('date ASC').first.date.beginning_of_month + + beginning_of_month = current_date.beginning_of_month + + # In this case, the only data we have is data in the current month and since the current month + # isn't over we should not be reporting. + if earliest_entry_beginning_of_month_date == beginning_of_month + raise Sushi::Error::UsageNotReadyForRequestedDatesError.new(data: "The only data available is for #{beginning_of_month.strftime('%Y-%m')}; a month that is not yet over.") + end + + earliest_entry_beginning_of_month_date + end + + ## + # @see .first_month_available + # + # @return [String] when there is a valid first month, return the YYYY-MM format of that month. + # @return [NilClass] when there is not a valid first month + def rescued_first_month_available(*args) + first_month_available(*args).strftime("%Y-%m") + rescue Sushi::Error::UsageNotReadyForRequestedDatesError + nil + end + + ## + # The earlier of: + # + # - the last day of the prior month + # - the last day of the month of the latest entry in {Hyrax::CounterMetric} + # + # @param current_date [Date] included as a dependency injection to ease testing. An assumption + # is that the current_date will always be on or after the last {Hyrax::CounterMetric} + # entry's date. + # + # @return [Date] when we have data in the system + # @raise [Sushi::Error::UsageNotReadyForRequestedDatesError] when we don't have data in the + # system + # + # @see {.first_month_available} + # + # @note Ultimately, the goal of these date ranges is for us to inform the consumer of the API + # about the complete months of data that we have. + def last_month_available(current_date: Time.zone.today) + # This might raise an exception + first_month_available(current_date: current_date) + + # We're assuming that we have whole months, so we'll nudge the latest date towards that + # assumption. + latest_entry_end_of_month_date = Hyrax::CounterMetric.order('date DESC').first.date.end_of_month + + # We want to avoid partial months, we look at the month prior to the current_date + end_of_last_month = 1.month.ago(current_date).end_of_month + + return latest_entry_end_of_month_date if latest_entry_end_of_month_date < end_of_last_month + + end_of_last_month + end + + ## + # @see .last_month_available + # + # @return [String] when there is a valid last month, return the YYYY-MM format of that month. + # @return [NilClass] when there is not a valid last month + def rescued_last_month_available(*args) + last_month_available(*args).strftime("%Y-%m") + rescue Sushi::Error::UsageNotReadyForRequestedDatesError + nil + end + end + + ## + # This module provides a common interface for validating parameters. + # + # @param params [Hash, ActionController::Parameters] + # @param allowed_parameters [Array] a list of query parameters AND report filters that are allowed. + # + # @return [String] + # @raise [Sushi::Error::UnrecognizedParameterError] when any unrecognized params are given. + module ParameterValidation + def validate_paramaters(params = {}, allowed_parameters: []) + filtered_params = params.keys.map(&:to_s) - ['action', 'controller', 'format'] + return true if (filtered_params & allowed_parameters).length == filtered_params.length + + raise Sushi::Error::UnrecognizedParameterError.new(data: "The given parameter(s) are invalid: #{(filtered_params - allowed_parameters).join(', ')}.") + end + end + + ## + # This module accounts for date behavior that needs to be used across all reports. + # + # @see #coerce_dates + module DateCoercion + extend ActiveSupport::Concern + included do + attr_reader :begin_date + attr_reader :end_date + end + + ## + # @param params [Hash, ActionController::Parameters] + # @option params [String, NilClass] begin_date :: Either nil, YYYY-MM or YYYY-MM-DD format + # @option params [String, NilClass] end_date :: Either nil, YYYY-MM or YYYY-MM-DD format + # + # @raise [Sushi::Error::InvalidDateArgumentError] when begin date is after end date + # @raise [Sushi::Error::UsageNoLongerAvailableForRequestedDatesError] when given begin date + # (coerced to beginning of month) is before earliest reporting date. + # @raise [Sushi::Error::UsageNoLongerAvailableForRequestedDatesError] when given end date + # (coerced to end of month) is after latest reporting date. + # @raise [Sushi::Error::InsufficientInformationToProcessRequestError] when either end date or + # begin date is not given. + def coerce_dates(params = {}) + # TODO: We should also be considering available dates as well. + + Sushi.validate_date_format([params.fetch(:begin_date), params.fetch(:end_date)]) + + # Because we're receiving user input that is likely strings, we need to do some coercion. + @begin_date = Sushi.coerce_to_date(params.fetch(:begin_date)).beginning_of_month + @end_date = Sushi.coerce_to_date(params.fetch(:end_date)).end_of_month + + raise Sushi::Error::InvalidDateArgumentError.new(data: "Begin date #{params.fetch(:begin_date)} is after end date #{params.fetch(:end_date)}.") if @begin_date > @end_date + + earliest_date = Hyrax::CounterMetric.order(date: :asc).first.date + if @begin_date < earliest_date + # rubocop:disable Metrics/LineLength + raise Sushi::Error::UsageNoLongerAvailableForRequestedDatesError.new(data: "Unable to complete the request because the begin_date of #{params[:begin_date]} is for a month that has incomplete data. That month's data starts on #{earliest_date.iso8601}.") + # rubocop:enable Metrics/LineLength + end + + latest_date = Hyrax::CounterMetric.order(date: :desc).first.date + if @end_date > latest_date + # rubocop:disable Metrics/LineLength + raise Sushi::Error::UsageNotReadyForRequestedDatesError.new(data: "Unable to complete the request because the end_date of #{params[:end_date]} is for a month that has incomplete data. That month's data ends on #{latest_date.iso8601}.") + # rubocop:enable Metrics/LineLength + end + rescue ActionController::ParameterMissing, KeyError => e + raise Sushi::Error::InsufficientInformationToProcessRequestError.new(data: e.message) + end + end + + ## + # This module provides coercion of the :data_type parameter + # + # @see #coerce_data_types + module DataTypeCoercion + extend ActiveSupport::Concern + included do + attr_reader :data_types, :data_type_in_params + end + + ## + # @param params [Hash, ActionController::Parameters] + # @option params [String, NilClass] data_type :: Pipe separated string of one or more data_types. + def coerce_data_types(params = {}) + @data_type_in_params = params.key?(:data_type) + @data_types = Array.wrap(params[:data_type]&.split('|')).map { |dt| dt.strip.downcase } + end + end + + module MetricTypeCoercion + extend ActiveSupport::Concern + included do + attr_reader :metric_types, :metric_type_in_params + end + + def coerce_metric_types(params = {}, allowed_types: ALLOWED_METRIC_TYPES) + metric_types_from_params = Array.wrap(params[:metric_type]&.split('|')) + return @metric_types = allowed_types if metric_types_from_params.empty? + + @metric_type_in_params = metric_types_from_params.any? do |metric_type| + normalized_metric_type = metric_type.downcase + allowed_types.any? { |allowed_type| allowed_type.downcase == normalized_metric_type } + end + + unless metric_type_in_params + raise Sushi::Error::InvalidReportFilterValueError.given_value_does_not_match_allowed_values( + parameter_value: params[:metric_type], + parameter_name: :metric_type, + allowed_values: allowed_types + ) + end + + @metric_types = metric_types_from_params.map do |metric_type| + normalized_metric_type = metric_type.downcase + metric_type.titleize.tr(' ', '_') if allowed_types.any? { |allowed_type| allowed_type.downcase == normalized_metric_type } + end.compact + end + end + + module AccessMethodCoercion + extend ActiveSupport::Concern + included do + attr_reader :access_methods, :access_method_in_params + end + + ALLOWED_ACCESS_METHODS = ['regular'].freeze + + ## + # @param params [Hash, ActionController::Parameters] + # + # @return [Array] + # @raise [Sushi::InvalidParameterValue] when the access method is invalid. + def coerce_access_method(params = {}) + return true unless params.key?(:access_method) + allowed_access_methods_from_params = Array.wrap(params[:access_method].split('|')).map { |am| am.strip.downcase } & ALLOWED_ACCESS_METHODS + + unless allowed_access_methods_from_params.any? + raise Sushi::Error::InvalidReportFilterValueError.given_value_does_not_match_allowed_values( + parameter_value: params[:access_method], + parameter_name: :access_method, + allowed_values: ALLOWED_ACCESS_METHODS + ) + end + + @access_methods = allowed_access_methods_from_params + @access_method_in_params = true + end + end + + module ItemIDCoercion + extend ActiveSupport::Concern + included do + attr_reader :item_id, :item_id_in_params + end + + ## + # @param params [Hash, ActionController::Parameters] + # + # @return [String] + # @raise [Sushi::Error::NotFoundError] when the item id has no metrics. + def coerce_item_id(params = {}) + return true unless params.key?(:item_id) + + # rubocop:disable Metrics/LineLength + raise Sushi::Error::InvalidReportFilterValueError.new(data: "The given parameter `item_id=#{params[:item_id]}` does not exist. Please provide an existing item_id, or none at all.") unless Hyrax::CounterMetric.exists?(work_id: params[:item_id]) + # rubocop:enable Metrics/LineLength + + @item_id = params[:item_id] + @item_id_in_params = true + end + end + + module PlatformCoercion + extend ActiveSupport::Concern + included do + attr_reader :platform, :platform_in_params + end + + ## + # @param params [Hash, ActionController::Parameters] + # @param account [Account] + # + # @return [String] + # @raise [Sushi::InvalidParameterValue] when the platform is invalid. + def coerce_platform(params = {}, account = nil) + return true unless params.key?(:platform) + + unless params[:platform] == account.cname + raise Sushi::Error::InvalidReportFilterValueError.given_value_does_not_match_allowed_values( + parameter_value: params[:platform], + parameter_name: :platform, + allowed_values: [account.cname] + ) + end + + @platform = account.cname + @platform_in_params = true + end + end + + # This param specifies the granularity of the usage data to include in the report. + # Permissible values are Month (default) and Totals. + # For Totals, each Item_Performance element represents the aggregated usage for the reporting period. + # See https://cop5.projectcounter.org/en/5.1/03-specifications/03-counter-report-common-attributes-and-elements.html#report-filters-and-report-attributes for details + module GranularityCoercion + extend ActiveSupport::Concern + included do + attr_reader :granularity, :granularity_in_params + end + ALLOWED_GRANULARITY = ["Month", "Totals"].freeze + + def coerce_granularity(params = {}) + return true unless params.key?(:granularity) + @granularity_in_params = ALLOWED_GRANULARITY.include?(params[:granularity].to_s.capitalize) + + unless @granularity_in_params + raise Sushi::Error::InvalidReportFilterValueError.given_value_does_not_match_allowed_values( + parameter_value: params[:granularity], + parameter_name: :granularity, + allowed_values: ALLOWED_GRANULARITY + ) + end + + @granularity = params.fetch(:granularity).capitalize + end + end + + module AuthorCoercion + extend ActiveSupport::Concern + included do + attr_reader :author, :author_in_params + end + + ## + # Because we have the potential for multiple authors for a work, we want to separate those + # authors in the single field. We've chosen the pipe (e.g. `|`) as that delimiter. + DELIMITER = "|" + + ## + # Deserialize the Ruby array into a serialize author format. + # + # @param string [String] + # @param delimiter [String] + # + # @see .serialize + def self.deserialize(string, delimiter: DELIMITER) + string.to_s.split(delimiter).select(&:present?) + end + + ## + # Convert the Ruby array into a serialize author format. + # + # @param array [Array] + # @param delimiter [String] + # + # @see .deserialize + def self.serialize(array, delimiter: DELIMITER) + array = Array.wrap(array) + return nil if array.empty? + + "#{delimiter}#{array.join(delimiter)}#{delimiter}" + end + + ## + # Ensure the given param is valid. + # + # @param params [Hash, ActionController::Parameters] + # @option params [String] :author the named parameter we'll use to filter the report items by. + # + # @note: The sushi spec states that this value must be >=2 characters. We're only enforcing that the value exactly + # matches whatever data we have in the database. Which presumably is >=2 characters, but may not be. + # + # @see #author_as_where_parameters + def coerce_author(params = {}) + return true unless params.key?(:author) + @author = params[:author] + + # See https://github.com/scientist-softserv/palni-palci/issues/721#issuecomment-1734215004 for details of this little nuance + raise Sushi::Error::InvalidReportFilterValueError.new(data: "You may not query for multiple authors (as specified by the `#{DELIMITER}' delimiter.)") if @author.include?(DELIMITER) + + # rubocop:disable Metrics/LineLength + raise Sushi::Error::InvalidReportFilterValueError.new(data: "The given author #{author.inspect} was not found in the metrics.") unless Hyrax::CounterMetric.where(author_as_where_parameters).exists? + # rubocop:enable Metrics/LineLength + + @author_in_params = true + end + + ## + # @return [Array] The {ActiveRecord::Base#where} can take an array of parameters, using + # those to build the SQL statement. The returned values is conformant to that method's + # interface. + # + # @see .serialize + # @see .deserialize + def author_as_where_parameters + # NOTE: I've included both the serialized handler and the non-serialized version; that way + # when we send this code change up, we don't also need to perform a migration. + # + # TODO: Remove the "OR author = ?" and the 3rd element of the array once we've run the imports + # with the latest changes brought about by the commit that introduced this comment. + ["author LIKE ? OR author = ?", "%#{DELIMITER}#{author}#{DELIMITER}%", author] + end + end + + module YearOfPublicationCoercion + extend ActiveSupport::Concern + included do + attr_reader :yop_as_where_parameters, :yop + end + + DATE_RANGE_REGEXP = /^(\d+)\s*-\s*(\d+)$/ + + ## + # Convert the given params to parameters suitable for {ActiveRecord::Base.where} calls. + # + # @param params [Hash] + # @option params [String] :yop the named parameter from which we'll extract integers. + # + # @return [Array] when all of the parts are valid, we'll have an array with the + # first element being a string (that has position "?"s for SQL query building) and the + # remaining elements being integers. + # @raise [ArgumentError] when part of the given YOP could not be coerced to an integer. + # + # @note No special consideration is made for date ranges that start with a later date and end with + # an earlier date (e.g. "1999-1994" will be "date >= 1999 AND date <= 1994"; which will + # return no entries.) + def coerce_yop(params = {}) + return unless params.key?(:yop) + + # TODO: We might want to quote the column name and add the table name as well; this helps with + # any potential field name collisions while we assemble the SQL statement. + field_name = 'year_of_publication' + where_clauses = [] + where_values = [] + + params[:yop].split(/\s*\|\s*/).flat_map do |slug| + slug = slug.strip + match = DATE_RANGE_REGEXP.match(slug) + if match + where_clauses << "(#{field_name} >= ? AND #{field_name} <= ?)" + where_values << Integer(match[1]) + where_values << Integer(match[2]) + else + where_clauses << "(#{field_name} = ?)" + where_values << Integer(slug) + end + end + @yop = params[:yop] + @yop_as_where_parameters = ["(#{where_clauses.join(' OR ')})"] + where_values + rescue ArgumentError + # rubocop:disable Metrics/LineLength + raise Sushi::Error::InvalidDateArgumentError.new(data: "The given parameter `yop=#{yop}` was malformed. You can provide a range (e.g. 'YYYY-YYYY') or a single date (e.g. 'YYYY'). You can separate ranges/values with a '|'.") + # rubocop:enable Metrics/LineLength + end + end +end diff --git a/app/models/sushi/error.rb b/app/models/sushi/error.rb new file mode 100644 index 0000000000..2ec9689d48 --- /dev/null +++ b/app/models/sushi/error.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Sushi + ## + # A name space for the various errors that we have. + # + # @note I'd love for this to be Sushi::Error::Errors, but Rails autoload might have other opinions. + module Error + ## + # @see https://countermetrics.stoplight.io/docs/counter-sushi-api/sgmqulr0emsi6-exception + # + # @see .as_json + class Exception < StandardError + class_attribute :code, default: nil + class_attribute :help_url, default: nil + class_attribute :default_message, default: nil + + attr_reader :data + def initialize(data: nil) + @data = data + super("#{default_message}: #{data}") + end + + ## + # In Sushi's exception documentation there are two required properties: + # + # - Code :: an integer + # - Message :: the Message value is a terse string that "identifies" the conceptual + # + # The Data property is the further explanation of the Message. And the Help_URL property + # is a URL that explains the exception. By convention, we're setting the Help_URL to the + # Sushi documentation; this helps developers read more about what the details of the + # exceptions; and provides the end-user with further details about the Sushi "specification". + # + # @see https://countermetrics.stoplight.io/docs/counter-sushi-api/sgmqulr0emsi6-exception + def as_json(*) + hash = { + Code: code, + Message: default_message + } + + hash[:Data] = data if data.present? + hash[:Help_URL] = help_url if help_url.present? + hash + end + end + + class InsufficientInformationToProcessRequestError < Sushi::Error::Exception + self.code = 1030 + self.help_url = "https://countermetrics.stoplight.io/docs/counter-sushi-api/sgise2c2tmbrq-exception-1030" + self.default_message = "Insufficient Information to Process Request" + end + + class InvalidDateArgumentError < Sushi::Error::Exception + self.code = 3020 + self.help_url = "https://countermetrics.stoplight.io/docs/counter-sushi-api/3anarphof7zh5-exception-3020" + self.default_message = "Invalid Date Arguments" + end + + class NoUsageAvailableForRequestedDatesError < Sushi::Error::Exception + self.code = 3030 + self.help_url = "https://countermetrics.stoplight.io/docs/counter-sushi-api/2fhcc5gkzzkg3-exception-3030" + self.default_message = "No Usage Available for Requested Dates" + end + + # @todo This is likely a scenario we'll need to handle. + class UsageNotReadyForRequestedDatesError < Sushi::Error::Exception + self.code = 3031 + self.help_url = "https://countermetrics.stoplight.io/docs/counter-sushi-api/6lfhilgndgpde-exception-3031" + self.default_message = "Usage Not Ready for Requested Dates" + end + + # @todo This is likely a scenario we'll need to handle. + class UsageNoLongerAvailableForRequestedDatesError < Sushi::Error::Exception + self.code = 3032 + self.help_url = "https://countermetrics.stoplight.io/docs/counter-sushi-api/x2o44d5p9kdp3-exception-3032" + self.default_message = "Usage No Longer Available for Requested Dates" + end + + class UnrecognizedParameterError < Sushi::Error::Exception + self.code = 3050 + self.help_url = "https://countermetrics.stoplight.io/docs/counter-sushi-api/mvg1sf79k34ni-exception-3050" + self.default_message = "Parameter Not Recognized in this Context" + end + + class InvalidReportFilterValueError < Sushi::Error::Exception + self.code = 3060 + self.help_url = "https://countermetrics.stoplight.io/docs/counter-sushi-api/s2a2xb68jsnn6-exception-3060" + self.default_message = "Invalid Report Filter Value" + + ## + # @param parameter_value [#to_s] + # @param parameter_name [#to_s] + # @param allowed_values [Array<#to_s>] + def self.given_value_does_not_match_allowed_values(parameter_value:, parameter_name:, allowed_values:) + new(data: "None of the given values in `#{parameter_name}=#{parameter_value}` are supported at this time. " \ + "Please use an acceptable value, (#{allowed_values.join(', ')}) instead. " \ + "(Or do not pass the parameter at all, which will default to the acceptable value(s))") + end + end + end + + ## + # The Info exception differs from the Error exception in that it is returned in the Report_Header. + # The message nor data are standarized, and therefore are being required to be passed in. + class Info + attr_reader :data, :message + + def initialize(data:, message:) + @data = data + @message = message + end + + def as_json(*) + { + Code: 0, + Message: message, + Help_URL: "https://countermetrics.stoplight.io/docs/counter-sushi-api/jmelferytrixm-exception-0", + Data: data + } + end + end +end diff --git a/app/models/sushi/item_report.rb b/app/models/sushi/item_report.rb new file mode 100644 index 0000000000..987e3375e4 --- /dev/null +++ b/app/models/sushi/item_report.rb @@ -0,0 +1,181 @@ +# frozen_string_literal:true + +# counter compliant format for ItemReport: https://countermetrics.stoplight.io/docs/counter-sushi-api/5a6e9f5ddae3e-ir-item-report +# +# dates will be filtered where both begin & end dates are inclusive. +# any provided begin_date will be moved to the beginning of the month +# any provided end_date will be moved to the end of the month +module Sushi + class ItemReport + attr_reader :account, :attributes_to_show, :created + include Sushi + include Sushi::AccessMethodCoercion + include Sushi::AuthorCoercion + include Sushi::DateCoercion + include Sushi::DataTypeCoercion + include Sushi::DateCoercion + include Sushi::ItemIDCoercion + include Sushi::MetricTypeCoercion + include Sushi::ParameterValidation + include Sushi::PlatformCoercion + include Sushi::YearOfPublicationCoercion + + ALLOWED_REPORT_ATTRIBUTES_TO_SHOW = [ + 'Access_Method', + # These are all the counter compliant query attributes, they are not currently supported in this implementation. + # 'Institution_Name', + # 'Customer_ID', + # 'Country_Name', + # 'Country_Code', + # 'Subdivision_Name', + # 'Subdivision_Code', + # 'Attributed' + ].freeze + + ALLOWED_METRIC_TYPES = [ + 'Total_Item_Investigations', + 'Total_Item_Requests', + 'Unique_Item_Investigations', + 'Unique_Item_Requests' + ].freeze + + ALLOWED_PARAMETERS = [ + 'access_method', + 'access_type', + 'api_key', + 'attributed', + 'attributes_to_show', + 'author', + 'begin_date', + 'country_code', + 'customer_id', + 'data_type', + 'end_date', + 'granularity', + 'include_component_details', + 'include_parent_details', + 'item_id', + 'metric_type', + 'platform', + 'requestor_id', + 'subdivision_code', + 'yop' + ].freeze + + def initialize(params = {}, created: Time.zone.now, account:) + Sushi.info = [] + validate_paramaters(params, allowed_parameters: ALLOWED_PARAMETERS) + coerce_access_method(params) + coerce_author(params) + coerce_data_types(params) + coerce_dates(params) + coerce_item_id(params) + coerce_metric_types(params, allowed_types: ALLOWED_METRIC_TYPES) + coerce_platform(params, account) + coerce_yop(params) + @account = account + @created = created + + # We want to limit the available attributes to be a subset of the given attributes; the `&` is + # the intersection of the two arrays. + @attributes_to_show = params.fetch(:attributes_to_show, ['Access_Method']) & ALLOWED_REPORT_ATTRIBUTES_TO_SHOW + end + + def as_json(_options = {}) + report_hash = { + 'Report_Header' => { + 'Report_Name' => 'Item Report', + 'Report_ID' => 'IR', + 'Release' => '5.1', + 'Institution_Name' => account.institution_name, + 'Institution_ID' => account.institution_id_data, + 'Report_Filters' => { + 'Begin_Date' => begin_date.iso8601, + 'End_Date' => end_date.iso8601 + }, + 'Created' => created.rfc3339, # '2023-02-15T09:11:12Z' + 'Created_By' => account.institution_name, + 'Registry_Record' => '', + 'Report_Attributes' => { + 'Attributes_To_Show' => attributes_to_show + } + }, + 'Report_Items' => report_items + } + + raise Sushi::Error::NoUsageAvailableForRequestedDatesError.new(data: "No data within #{begin_date.iso8601} and #{end_date.iso8601} date range.") if report_items.blank? + + report_hash['Report_Header']['Report_Filters']['Access_Method'] = access_methods if access_method_in_params + report_hash['Report_Header']['Report_Filters']['Author'] = author if author_in_params + report_hash['Report_Header']['Report_Filters']['Data_Type'] = data_types if data_type_in_params + report_hash['Report_Header']['Report_Filters']['Item_ID'] = item_id if item_id_in_params + report_hash['Report_Header']['Report_Filters']['Metric_Type'] = metric_types if metric_type_in_params + report_hash['Report_Header']['Report_Filters']['Platform'] = platform if platform_in_params + report_hash['Report_Header']['Report_Filters']['YOP'] = yop if yop + report_hash["Report_Header"]["Exceptions"] = info if info.present? + + report_hash + end + + alias to_hash as_json + + def report_items + data_for_resource_types.map do |record| + { + 'Items' => [{ + 'Attribute_Performance' => [{ + 'Data_Type' => record.resource_type.titleize, + # We are only supporting the 'Regular' access method at this time. If that changes, we will need to update this. + 'Access_Method' => 'Regular', + 'Performance' => performance(record) + }], + 'Authors' => Sushi::AuthorCoercion.deserialize(record.author).map { |author| { 'Name:' => author } }, + 'Item' => record.work_id.to_s, + 'Publisher' => '', + 'Platform' => account.cname, + 'Item_ID' => { + 'Proprietary': record.work_id.to_s, + 'URI': "https://#{account.cname}/concern/#{record.worktype.underscore}s/#{record.work_id}" + } + }] + } + end + end + + def performance(record) + metric_types.each_with_object({}) do |metric_type, returning_hash| + returning_hash[metric_type] = record.performance.each_with_object({}) do |cell, hash| + hash[cell.fetch('year_month')] = cell.fetch(metric_type) + end + end + end + + def data_for_resource_types + relation = Hyrax::CounterMetric + .select(:work_id, :resource_type, :worktype, :author, + %((SELECT To_json(Array_agg(Row_to_json(t))) + FROM + (SELECT + -- The AS field_name needs to be double quoted so as to preserve case structure. + SUM(total_item_investigations) as "Total_Item_Investigations", + SUM(total_item_requests) as "Total_Item_Requests", + COUNT(DISTINCT CASE WHEN total_item_investigations IS NOT NULL THEN CONCAT(work_id, '_', date::text) END) as "Unique_Item_Investigations", + COUNT(DISTINCT CASE WHEN total_item_requests IS NOT NULL THEN CONCAT(work_id, '_', date::text) END) as "Unique_Item_Requests", + -- We need to coerce the month from a single digit to two digits (e.g. August's "8" into "08") + CONCAT(DATE_PART('year', date_trunc('month', date)), '-', to_char(DATE_PART('month', date_trunc('month', date)), 'fm00')) AS year_month + FROM hyrax_counter_metrics AS aggr + WHERE #{Hyrax::CounterMetric.sanitize_sql_for_conditions(['aggr.work_id = hyrax_counter_metrics.work_id AND date >= ? AND date <= ?', begin_date, end_date])} + GROUP BY date_trunc('month', date)) t) as performance)) + .where("date >= ? AND date <= ?", begin_date, end_date) + .order(resource_type: :asc, work_id: :asc) + .group(:work_id, :resource_type, :worktype, :author) + + relation = relation.where("(?) = work_id", item_id) if item_id + relation = relation.where(author_as_where_parameters) if author.present? + relation = relation.where(yop_as_where_parameters) if yop_as_where_parameters.present? + relation = relation.where("LOWER(resource_type) IN (?)", data_types) if data_types.any? + + relation + end + end +end diff --git a/app/models/sushi/platform_report.rb b/app/models/sushi/platform_report.rb new file mode 100644 index 0000000000..0103035bdc --- /dev/null +++ b/app/models/sushi/platform_report.rb @@ -0,0 +1,221 @@ +# frozen_string_literal:true + +# counter compliant format for the PlatformReport is found here: https://countermetrics.stoplight.io/docs/counter-sushi-api/e98e9f5cab5ed-pr-platform-report +# +# dates will be filtered where both begin & end dates are inclusive. +# any provided begin_date will be moved to the beginning of the month +# any provided end_date will be moved to the end of the month +module Sushi + class PlatformReport + attr_reader :created, :account, :attributes_to_show + include Sushi + include Sushi::AccessMethodCoercion + include Sushi::DataTypeCoercion + include Sushi::DateCoercion + include Sushi::GranularityCoercion + include Sushi::MetricTypeCoercion + include Sushi::ParameterValidation + include Sushi::PlatformCoercion + + ALLOWED_REPORT_ATTRIBUTES_TO_SHOW = [ + "Access_Method", + # These are all the counter compliant query attributes, they are not currently supported in this implementation. + # "Institution_Name", + # "Customer_ID", + # "Country_Name", + # "Country_Code", + # "Subdivision_Name", + # "Subdivision_Code", + # "Attributed" + ].freeze + + ALLOWED_METRIC_TYPES = [ + "Searches_Platform", + "Total_Item_Investigations", + "Total_Item_Requests", + "Unique_Item_Investigations", + "Unique_Item_Requests", + # Unique_Title metrics exist to count how many chapters or sections are accessed for Book resource types in a given user session. + # This implementation currently does not support historical data from individual chapters/sections of Books, + # so these metrics will not be shown. + # See https://cop5.projectcounter.org/en/5.1/03-specifications/03-counter-report-common-attributes-and-elements.html#metric-types for details + # "Unique_Title_Investigations", + # "Unique_Title_Requests" + ].freeze + + ALLOWED_PARAMETERS = [ + 'access_method', + 'api_key', + 'attributed', + 'attributes_to_show', + 'author', + 'begin_date', + 'country_code', + 'customer_id', + 'data_type', + 'end_date', + 'granularity', + 'include_component_details', + 'include_parent_details', + 'item_id', + 'metric_type', + 'platform', + 'requestor_id', + 'subdivision_code', + 'yop' + ].freeze + + def initialize(params = {}, created: Time.zone.now, account:) + Sushi.info = [] + validate_paramaters(params, allowed_parameters: ALLOWED_PARAMETERS) + coerce_access_method(params) + coerce_data_types(params) + coerce_dates(params) + coerce_granularity(params) + coerce_metric_types(params, allowed_types: ALLOWED_METRIC_TYPES) + coerce_platform(params, account) + @created = created + @account = account + + # We want to limit the available attributes to be a subset of the given attributes; the `&` is + # the intersection of the two arrays. + @attributes_to_show = params.fetch(:attributes_to_show, ["Access_Method"]) & ALLOWED_REPORT_ATTRIBUTES_TO_SHOW + end + + def as_json(_options = {}) + report_hash = { + "Report_Header" => { + "Release" => "5.1", + "Report_ID" => "PR", + "Report_Name" => "Platform Report", + "Created" => created.rfc3339, # "2023-02-15T09:11:12Z" + "Created_By" => account.institution_name, + "Institution_ID" => account.institution_id_data, + "Institution_Name" => account.institution_name, + "Registry_Record" => "", + "Report_Attributes" => { + "Attributes_To_Show" => attributes_to_show + }, + "Report_Filters" => { + # TODO: handle YYYY-MM format + "Begin_Date" => begin_date.iso8601, + "End_Date" => end_date.iso8601 + } + }, + "Report_Items" => { + "Platform" => account.cname, + "Attribute_Performance" => attribute_performance_for_resource_types + attribute_performance_for_platform + } + } + report_hash["Report_Header"]["Report_Filters"]["Access_Method"] = access_methods if access_method_in_params + report_hash["Report_Header"]["Report_Filters"]["Data_Type"] = data_types if data_type_in_params + report_hash["Report_Header"]["Report_Attributes"]["Granularity"] = granularity if granularity_in_params + report_hash["Report_Header"]["Report_Filters"]["Metric_Type"] = metric_types if metric_type_in_params + report_hash["Report_Header"]["Report_Filters"]["Platform"] = platform if platform_in_params + report_hash["Report_Header"]["Exceptions"] = info if info.present? + + report_hash + end + + alias to_hash as_json + + def attribute_performance_for_resource_types + # We want to consider "or" behavior for multiple metric_types. Namely if you specify any + # metric type (other than Searches_Platform) you're going to get results. + # + # See https://github.com/scientist-softserv/palni-palci/issues/686#issuecomment-1785326034 + return [] if metric_type_in_params && (metric_types & (ALLOWED_METRIC_TYPES - ['Searches_Platform'])).count.zero? + + data_for_resource_types.map do |record| + { + "Data_Type" => record.resource_type, + "Access_Method" => "Regular", + "Performance" => performance(record) + } + end + end + + def attribute_performance_for_platform + return [] if metric_type_in_params && metric_types.exclude?("Searches_Platform") + return [] if data_type_in_params && !data_types.find { |dt| dt.casecmp("Platform").zero? } + + [{ + "Data_Type" => "Platform", + "Access_Method" => "Regular", + "Performance" => { + "Searches_Platform" => if granularity == 'Totals' + total_for_platform = data_for_platform.sum(&:total_item_investigations) + { "Totals" => total_for_platform } + else + data_for_platform.each_with_object({}) do |record, hash| + hash[record.year_month.strftime("%Y-%m")] = record.total_item_investigations + hash + end + end + } + }] + end + + def performance(record) + metric_types.each_with_object({}) do |metric_type, returning_hash| + next if metric_type == "Searches_Platform" + + returning_hash[metric_type] = + if granularity == 'Totals' + { "Totals" => record.performance.sum { |hash| hash.fetch(metric_type) } } + else + record.performance.each_with_object({}) do |cell, hash| + hash[cell.fetch('year_month')] = cell.fetch(metric_type) + end + end + end + end + + ## + # @note the `date_trunc` SQL function is specific to Postgresql. It will take the date/time field + # value and return a date/time object that is at the exact start of the date specificity. + # + # For example, if we had "2023-01-03T13:14" and asked for the date_trunc of month, the + # query result value would be "2023-01-01T00:00" (e.g. the first moment of the first of the + # month). + # also, note that unique_item_requests and unique_item_investigations should be counted for Hyrax::CounterMetrics that have unique dates, and unique work IDs. + # see the docs for counting unique items here: https://cop5.projectcounter.org/en/5.1/07-processing/03-counting-unique-items.html + # rubocop:disable Metrics/LineLength + def data_for_resource_types + # We're capturing this relation/query because in some cases, we need to chain another where + # clause onto the relation. + relation = Hyrax::CounterMetric + .select(:resource_type, :worktype, + %((SELECT To_json(Array_agg(Row_to_json(t))) + FROM + (SELECT + -- The AS field_name needs to be double quoted so as to preserve case structure. + SUM(total_item_investigations) as "Total_Item_Investigations", + SUM(total_item_requests) as "Total_Item_Requests", + COUNT(DISTINCT CASE WHEN total_item_investigations IS NOT NULL THEN CONCAT(work_id, '_', date::text) END) as "Unique_Item_Investigations", + COUNT(DISTINCT CASE WHEN total_item_requests IS NOT NULL THEN CONCAT(work_id, '_', date::text) END) as "Unique_Item_Requests", + -- We need to coerce the month from a single digit to two digits (e.g. August's "8" into "08") + CONCAT(DATE_PART('year', date_trunc('month', date)), '-', to_char(DATE_PART('month', date_trunc('month', date)), 'fm00')) AS year_month + FROM hyrax_counter_metrics AS aggr + WHERE #{Hyrax::CounterMetric.sanitize_sql_for_conditions(['aggr.resource_type = hyrax_counter_metrics.resource_type AND date >= ? AND date <= ?', begin_date, end_date])} + GROUP BY date_trunc('month', date)) t) as performance)) + .where("date >= ? AND date <= ?", begin_date, end_date) + .order(resource_type: :asc) + .group(:resource_type, :worktype) + return relation if data_types.blank? + + relation.where("LOWER(resource_type) IN (?)", data_types) + end + # rubocop:enable Metrics/LineLength + + def data_for_platform + Hyrax::CounterMetric + .select("date_trunc('month', date) AS year_month", + "SUM(total_item_investigations) as total_item_investigations", + "SUM(total_item_requests) as total_item_requests") + .where("date >= ? AND date <= ?", begin_date, end_date) + .order("year_month") + .group("date_trunc('month', date)") + end + end +end diff --git a/app/models/sushi/platform_usage_report.rb b/app/models/sushi/platform_usage_report.rb new file mode 100644 index 0000000000..0e73fb47a6 --- /dev/null +++ b/app/models/sushi/platform_usage_report.rb @@ -0,0 +1,160 @@ +# frozen_string_literal:true + +# counter compliant format for the Platform Usage Report is found here: https://countermetrics.stoplight.io/docs/counter-sushi-api/82bd896d1dd60-pr-p1-platform-usage +# +# dates will be filtered by including the begin_date, and excluding the end_date +# e.g. if you want to full month, begin_date should be the first day of that month, and end_date should be the first day of the following month. +module Sushi + class PlatformUsageReport + attr_reader :created, :account + include Sushi + include Sushi::DataTypeCoercion + include Sushi::DateCoercion + include Sushi::MetricTypeCoercion + include Sushi::ParameterValidation + + # the platform usage report only contains requests. see https://countermetrics.stoplight.io/docs/counter-sushi-api/mgu8ibcbgrwe0-pr-p1-performance-other for details + ALLOWED_METRIC_TYPES = [ + "Searches_Platform", + "Total_Item_Requests", + "Unique_Item_Requests" + ].freeze + + ALLOWED_PARAMETERS = [ + 'access_method', + 'api_key', + 'begin_date', + 'customer_id', + 'end_date', + 'metric_type', + 'platform', + 'requestor_id' + ].freeze + + def initialize(params = {}, created: Time.zone.now, account:) + Sushi.info = [] + validate_paramaters(params, allowed_parameters: ALLOWED_PARAMETERS) + coerce_data_types(params) + coerce_dates(params) + coerce_metric_types(params, allowed_types: ALLOWED_METRIC_TYPES) + + @created = created + @account = account + end + + def as_json(_options = {}) + report_hash = { + "Report_Header" => { + "Release" => "5.1", + "Report_ID" => "PR_P1", + "Report_Name" => "Platform Usage", + "Created" => created.rfc3339, # "2023-02-15T09:11:12Z" + "Created_By" => account.institution_name, + "Institution_ID" => account.institution_id_data, + "Institution_Name" => account.institution_name, + "Registry_Record" => "", + "Report_Filters" => { + "Begin_Date" => begin_date.iso8601, + "End_Date" => end_date.iso8601, + "Access_Method" => [ + "Regular" + ] + } + }, + "Report_Items" => { + "Platform" => account.cname, + "Attribute_Performance" => attribute_performance_for_resource_types + attribute_performance_for_platform + } + } + report_hash["Report_Header"]["Report_Filters"]["Data_Type"] = data_types if data_type_in_params + report_hash["Report_Header"]["Report_Filters"]["Metric_Type"] = metric_types if metric_type_in_params + report_hash["Report_Header"]["Exceptions"] = info if info.present? + + report_hash + end + alias to_hash as_json + + def attribute_performance_for_resource_types + return [] if metric_type_in_params && metric_types.include?("Searches_Platform") + + data_for_resource_types.map do |record| + { + "Data_Type" => record.resource_type, + "Access_Method" => "Regular", + "Performance" => performance(record) + } + end + end + + def performance(record) + metric_types.each_with_object({}) do |metric_type, returning_hash| + next if metric_type == "Searches_Platform" + + returning_hash[metric_type] = record.performance.each_with_object({}) do |cell, hash| + hash[cell.fetch('year_month')] = cell.fetch(metric_type) + end + end + end + + def attribute_performance_for_platform + return [] if metric_type_in_params && metric_types.exclude?("Searches_Platform") + + [{ + "Data_Type" => "Platform", + "Access_Method" => "Regular", + "Performance" => { + "Searches_Platform" => data_for_platform.each_with_object({}) do |record, hash| + hash[record.year_month.strftime("%Y-%m")] = record.total_item_investigations + hash + end + } + }] + end + + ## + # @note the `date_trunc` SQL function is specific to Postgresql. It will take the date/time field + # value and return a date/time object that is at the exact start of the date specificity. + # + # For example, if we had "2023-01-03T13:14" and asked for the date_trunc of month, the + # query result value would be "2023-01-01T00:00" (e.g. the first moment of the first of the + # month). + # rubocop:disable Metrics/LineLength + def data_for_resource_types + # We're capturing this relation/query because in some cases, we need to chain another where + # clause onto the relation. + relation = Hyrax::CounterMetric + .select(:resource_type, :worktype, + %((SELECT To_json(Array_agg(Row_to_json(t))) + FROM + (SELECT + -- The AS field_name needs to be double quoted so as to preserve case structure. + SUM(total_item_investigations) as "Total_Item_Investigations", + SUM(total_item_requests) as "Total_Item_Requests", + COUNT(DISTINCT CASE WHEN total_item_investigations IS NOT NULL THEN CONCAT(work_id, '_', date::text) END) as "Unique_Item_Investigations", + COUNT(DISTINCT CASE WHEN total_item_requests IS NOT NULL THEN CONCAT(work_id, '_', date::text) END) as "Unique_Item_Requests", + -- We need to coerce the month from a single digit to two digits (e.g. August's "8" into "08") + CONCAT(DATE_PART('year', date_trunc('month', date)), '-', to_char(DATE_PART('month', date_trunc('month', date)), 'fm00')) AS year_month + FROM hyrax_counter_metrics AS aggr + WHERE #{Hyrax::CounterMetric.sanitize_sql_for_conditions(['aggr.resource_type = hyrax_counter_metrics.resource_type AND date >= ? AND date <= ?', begin_date, end_date])} + GROUP BY date_trunc('month', date)) t) as performance)) + .where("date >= ? AND date <= ?", begin_date, end_date) + .order(resource_type: :asc) + .group(:resource_type, :worktype) + + return relation if data_types.blank? + + relation.where("LOWER(resource_type) IN (?)", data_types) + end + # rubocop:enable Metrics/LineLength + + def data_for_platform + Hyrax::CounterMetric + .select("date_trunc('month', date) AS year_month", + "SUM(total_item_investigations) as total_item_investigations", + "SUM(total_item_requests) as total_item_requests") + .where("date >= ? AND date <= ?", begin_date, end_date) + .order("year_month") + .group("date_trunc('month', date)") + end + end +end diff --git a/app/models/sushi/report_list.rb b/app/models/sushi/report_list.rb new file mode 100644 index 0000000000..a12a303cbf --- /dev/null +++ b/app/models/sushi/report_list.rb @@ -0,0 +1,55 @@ +# frozen_string_literal:true + +# counter compliant format for ReportList: https://countermetrics.stoplight.io/docs/counter-sushi-api/af75bac10e789-report-list +module Sushi + class ReportList + # rubocop:disable Metrics/MethodLength, Metrics/LineLength + def as_json(_options = nil) + [ + { + "Report_Name" => "Server Status", + "Report_ID" => "status", + "Release" => "5.1", + "Report_Description" => "This resource returns the current status of the reporting service supported by this API.", + "Path" => "/api/sushi/r51/status" + }, + { + "Report_Name" => "Report List", + "Report_ID" => "reports", + "Release" => "5.1", + "Report_Description" => "This resource returns a list of reports supported by the API for a given application.", + "Path" => "/api/sushi/r51/reports" + }, + { + "Report_Name" => "Platform Report", + "Report_ID" => "pr", + "Release" => "5.1", + "Report_Description" => "This resource returns COUNTER 'Platform Master Report' [PR]. A customizable report summarizing activity across a provider’s platforms that allows the user to apply filters and select other configuration options for the report.", + "Path" => "/api/sushi/r51/reports/pr", + "First_Month_Available" => Sushi.rescued_first_month_available, + "Last_Month_Available" => Sushi.rescued_last_month_available + }, + { + "Report_Name" => "Platform Usage", + "Report_ID" => "pr_p1", + "Release" => "5.1", + "Report_Description" => "This resource returns COUNTER 'Platform Usage' [pr_p1]. This is a Standard View of the Package Master Report that presents usage for the overall Platform broken down by Metric_Type.", + "Path" => "/api/sushi/r51/reports/pr_p1", + "First_Month_Available" => Sushi.rescued_first_month_available, + "Last_Month_Available" => Sushi.rescued_last_month_available + }, + { + "Report_Name" => "Item Report", + "Report_ID" => "ir", + "Release" => "5.1", + "Report_Description" => "This resource returns COUNTER 'Item Master Report' [IR].", + "Path" => "/api/sushi/r51/reports/ir", + "First_Month_Available" => Sushi.rescued_first_month_available, + "Last_Month_Available" => Sushi.rescued_last_month_available + } + ] + end + alias to_hash as_json + # rubocop:enable Metrics/MethodLength, Metrics/LineLength + end +end diff --git a/app/models/sushi/server_status.rb b/app/models/sushi/server_status.rb new file mode 100644 index 0000000000..60dc584e6d --- /dev/null +++ b/app/models/sushi/server_status.rb @@ -0,0 +1,22 @@ +# frozen_string_literal:true + +# counter compliant format for the Server Status Endpoint is found here: https://countermetrics.stoplight.io/docs/counter-sushi-api/f0dd30f814944-server-status +module Sushi + class ServerStatus + attr_reader :account + + def initialize(account:) + @account = account + end + + def server_status + [ + { + "Description" => "COUNTER Usage Reports for #{account.cname} platform.", + "Service_Active" => true, + "Registry_Record" => "" + } + ] + end + end +end diff --git a/app/presenters/hyku/menu_presenter.rb/menu_presenter.rb b/app/presenters/hyku/menu_presenter.rb/menu_presenter.rb new file mode 100644 index 0000000000..807c67c451 --- /dev/null +++ b/app/presenters/hyku/menu_presenter.rb/menu_presenter.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Hyku + # view-model for the admin menu + class MenuPresenter < Hyrax::MenuPresenter + # Returns true if the current controller happens to be one of the controllers that deals + # with settings. This is used to keep the parent section on the sidebar open. + def settings_section? + %w[appearances content_blocks labels features pages].include?(controller_name) + end + + # Returns true if the current controller happens to be one of the controllers that deals + # with roles and permissions. This is used to keep the parent section on the sidebar open. + def roles_and_permissions_section? + # we're using a case here because we need to differentiate UsersControllers + # in different namespaces (Hyrax & Admin) + case controller + when Hyrax::Admin::UsersController, ::Admin::GroupsController + true + else + false + end + end + + # Returns true if the current controller happens to be one of the controllers that deals + # with repository activity This is used to keep the parent section on the sidebar open. + def repository_activity_section? + %w[admin dashboard status].include?(controller_name) + end + + # Returns true if we ought to show the user the 'Configuration' section + # of the menu + def show_configuration? + super || + can?(:manage, Site) || + can?(:manage, User) || + can?(:manage, Hyrax::Group) + end + + def display_workflow_roles_menu_item_in_admin_dashboard_sidebar? + Flipflop.show_workflow_roles_menu_item_in_admin_dashboard_sidebar? + end + + # Returns true if we ought to show the user Admin-only areas of the menu + def show_admin_menu_items? + can?(:read, :admin_dashboard) + end + + def show_task? + can?(:review, :submissions) || + can?(:read, User) || + can?(:read, Hyrax::Group) || + can?(:read, :admin_dashboard) + end + end +end diff --git a/app/services/generate_counter_metrics.rb b/app/services/generate_counter_metrics.rb new file mode 100644 index 0000000000..9b427254ad --- /dev/null +++ b/app/services/generate_counter_metrics.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# Creates counter metrics for a single tenant. +class GenerateCounterMetrics + def self.generate_counter_metrics(ids: :all, limit: :all) + fsq = "has_model_ssim: (#{Bulkrax.curation_concerns.join(' OR ')})" + fsq += " AND id:(\"" + Array.wrap(ids).join('" OR "') + "\")" if ids.present? && ids != :all + options = { fl: "id, has_model_ssim, resource_type_tesim, date_ssi, creator_tesim, publisher_tesim, title_tesim", method: :post } + options[:rows] = limit if limit.is_a?(Numeric) + ActiveFedora::SolrService.query(fsq, options).each do |work| + work_type = work.fetch('has_model_ssim').first + work_id = work.fetch('id') + resource_type = work.fetch('resource_type_tesim').first + year_of_publication = work.fetch('date_ssi') + author = Sushi::AuthorCoercion.serialize(work.fetch('creator_tesim')) + publisher = work.fetch('publisher_tesim')&.first if work['publisher_tesim'] + title = work.fetch('title_tesim')&.first if work['title_tesim'] + (0...90).each do |i| + date = i.days.ago.to_date + total_item_investigations = rand(1..10) + total_item_requests = rand(1..10) + + Hyrax::CounterMetric.create!( + worktype: work_type, + work_id: work_id, + resource_type: resource_type, + date: date, + year_of_publication: year_of_publication, + author: author, + publisher: publisher, + title: title, + total_item_investigations: total_item_investigations, + total_item_requests: total_item_requests + ) + end + end + message = "#{self.class}.generate_counter_metrics data for IDs = #{ids.inspect} with Limit = #{limit.inspect}." + Rails.logger.info message + end +end diff --git a/app/services/hyrax/collections/collection_member_search_service_decorator.rb b/app/services/hyrax/collections/collection_member_search_service_decorator.rb new file mode 100644 index 0000000000..1fa07cc1a5 --- /dev/null +++ b/app/services/hyrax/collections/collection_member_search_service_decorator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# OVERRIDE Hyrax v5.0.0 to sort subcollections by title +module Hyrax + module Collections + module CollectionMemberSearchServiceDecorator + DEFAULT_SORT_FIELD = 'title_ssi asc' + + def available_member_works + sort_field = user_params[:sort] || DEFAULT_SORT_FIELD + response, _docs = search_results do |builder| + builder.search_includes_models = :works + builder.merge(sort: sort_field) + builder + end + response + end + end + end +end + +Hyrax::Collections::CollectionMemberSearchService.prepend Hyrax::Collections::CollectionMemberSearchServiceDecorator diff --git a/app/services/hyrax/solr_query_service_decorator.rb b/app/services/hyrax/solr_query_service_decorator.rb new file mode 100644 index 0000000000..bae21e26da --- /dev/null +++ b/app/services/hyrax/solr_query_service_decorator.rb @@ -0,0 +1,13 @@ +# OVERRIDE: Hyrax 5.0.0 changes GET request to POST to allow for larger query size + +# frozen_string_literal: true + +module Hyrax + module SolrQueryServiceDecorator + def get(*args) + solr_service.post(build, *args) + end + end +end + +Hyrax::SolrQueryService.prepend(Hyrax::SolrQueryServiceDecorator) diff --git a/app/services/import_counter_metrics.rb b/app/services/import_counter_metrics.rb new file mode 100644 index 0000000000..549fd67a84 --- /dev/null +++ b/app/services/import_counter_metrics.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +# Import counter views and downloads for a given tenant +# Currently, this only imports the first resource type. Additional work would need to be done for works with multiple resource types. +# The resource_type field in the counter_metrics table is single value and not an array. +class ImportCounterMetrics + # rubocop:disable Metrics/MethodLength, BlockLength + def self.import_investigations(csv_path) + CSV.foreach(csv_path, headers: true) do |row| + work = ActiveFedora::Base.where(bulkrax_identifier_tesim: row['eprintid']).first + next if work.nil? + worktype = work.class + work_id = work.id + resource_type = work.resource_type&.first + date = row['datestamp'] + year_of_publication = work.date + author = Sushi::AuthorCoercion.serialize(work.creator) + publisher = work.publisher&.first + title = work.title&.first + total_item_investigations = row['count'] + counter_investigation = Hyrax::CounterMetric.find_by(work_id: work_id, date: date) + if counter_investigation.present? + counter_investigation.update( + worktype: worktype, + work_id: work_id, + resource_type: resource_type, + date: date, + total_item_investigations: total_item_investigations, + year_of_publication: year_of_publication, + author: author, + publisher: publisher, + title: title + ) + else + Hyrax::CounterMetric.create!( + worktype: worktype, + work_id: work_id, + resource_type: resource_type, + date: date, + total_item_investigations: total_item_investigations, + year_of_publication: year_of_publication, + author: author, + publisher: publisher, + title: title + ) + end + end + end + + def self.import_requests(csv_path) + CSV.foreach(csv_path, headers: true) do |row| + work = ActiveFedora::Base.where(bulkrax_identifier_tesim: row['eprintid']).first + next if work.nil? + worktype = work.class + work_id = work.id + resource_type = work.resource_type.first + date = row['datestamp'] + year_of_publication = work.date + author = Sushi::AuthorCoercion.serialize(work.creator) + publisher = work.publisher&.first + title = work.title&.first + total_item_requests = row['count'] + counter_request = Hyrax::CounterMetric.find_by(work_id: work_id, date: date) + if counter_request.present? + counter_request.update( + worktype: worktype, + work_id: work_id, + resource_type: resource_type, + date: date, + total_item_requests: total_item_requests, + year_of_publication: year_of_publication, + author: author, + publisher: publisher, + title: title + ) + else + Hyrax::CounterMetric.create!( + worktype: worktype, + work_id: work_id, + resource_type: resource_type, + date: date, + total_item_requests: total_item_requests, + year_of_publication: year_of_publication, + author: author, + publisher: publisher, + title: title + ) + end + end + end + # rubocop:enable Metrics/MethodLength, Metrics/BlockLength +end diff --git a/lib/hyrax/controlled_vocabularies/location_decorator.rb b/lib/hyrax/controlled_vocabularies/location_decorator.rb new file mode 100644 index 0000000000..9dff981148 --- /dev/null +++ b/lib/hyrax/controlled_vocabularies/location_decorator.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Hyrax + module ControlledVocabularies + module LocationDecorator + # the Location class throws an error that split is undefined since it is an enumerator, but this error is coming from deeply nested dependencies. + # This causes 500 errors for works with their Location field filled in. + # defining split just for the purpose of getting past this error seemed to work and allow locations to save correctly with no adverse effects. + # See https://github.com/scientist-softserv/palni-palci/issues/530 for more details + def split(*) + [] + end + + def present? + return true if id + + false + end + + def rdf_label + return [id.gsub('http://fake#', '').gsub('%20', ' ')] if fake_location? + + super + end + + def full_label + return rdf_label.first.to_s if fake_location? + + super + end + + def fake_location? + id.start_with?('http://fake#') + end + end + end +end + +Hyrax::ControlledVocabularies::Location.prepend(Hyrax::ControlledVocabularies::LocationDecorator) diff --git a/lib/tasks/counter_metrics.rake b/lib/tasks/counter_metrics.rake new file mode 100644 index 0000000000..213662c7a6 --- /dev/null +++ b/lib/tasks/counter_metrics.rake @@ -0,0 +1,47 @@ +namespace :counter_metrics do + # bundle exec rake counter_metrics:import_investigations['pittir.hykucommons.org','spec/fixtures/csv/pittir-views.csv'] + desc 'import historical counter requests' + task 'import_requests', [:tenant_cname, :csv_path] => [:environment] do |_cmd, args| + raise ArgumentError, 'A tenant cname is required: `rake counter_metrics:import_requests[tenant_cname]`' if args.tenant_cname.blank? + raise ArgumentError, "Tenant not found: #{args.tenant_cname}. Are you sure this is the correct tenant cname?" unless Account.where(cname: args.tenant_cname).first + AccountElevator.switch!(args.tenant_cname) + puts "Importing historical requests for #{args.tenant_cname}" + ImportCounterMetrics.import_requests(args.csv_path) + end + + # bundle exec rake counter_metrics:import_requests['pittir.hykucommons.org','spec/fixtures/csv/pittir-downloads.csv'] + desc 'import historical counter investigations' + task 'import_investigations', [:tenant_cname, :csv_path] => [:environment] do |_cmd, args| + raise ArgumentError, 'A tenant cname is required: `rake counter_metrics:import_investigations[tenant_cname]`' if args.tenant_cname.blank? + raise ArgumentError, "Tenant not found: #{args.tenant_cname}. Are you sure this is the correct tenant cname?" unless Account.where(cname: args.tenant_cname).first + AccountElevator.switch!(args.tenant_cname) + puts "Importing historical investigations for #{args.tenant_cname}" + ImportCounterMetrics.import_investigations(args.csv_path) + end + + # You can optionally pass work IDs to have hyrax counter metrics created for specific works. + # Or, you can pass a limit for the number of CounterMetric entries you would like to create. currently they are randomly created. + # + # Example for pitt tenant in dev without ids: + # bundle exec rake counter_metrics:generate_staging_metrics[pitt.hyku.test] + # + # Example with ids: + # bundle exec rake "counter_metrics:generate_staging_metrics[pitt.hyku.test, ab3c1f9d-684a-4c14-93b1-75586ec05f7a|891u493hdfhiu939]" + # + # Example with limit of 1: + # bundle exec rake "counter_metrics:generate_staging_metrics[pitt.hyku.test, , 1]" + # + # NOTE: this should never be run in prod. + # It generates fake data, so running in prod would risk contaminating prod data. + desc 'generate counter metric test data for staging' + task 'generate_staging_metrics', [:tenant_cname, :ids, :limit] => [:environment] do |_cmd, args| + raise ArgumentError, 'A tenant cname is required: `rake counter_metrics:generate_staging_metrics[tenant_cname]`' if args.tenant_cname.blank? + kwargs = {} + kwargs[:ids] = args.ids.split("|") if args.ids.present? + kwargs[:limit] = Integer(args.limit) if args.limit.present? + AccountElevator.switch!(args.tenant_cname) + puts "Creating test counter metric data for #{args.tenant_cname}" + GenerateCounterMetrics.generate_counter_metrics(**kwargs) + puts 'Test data created successfully' + end +end diff --git a/lib/tasks/sidekiq.rake b/lib/tasks/sidekiq.rake new file mode 100644 index 0000000000..693118dd5e --- /dev/null +++ b/lib/tasks/sidekiq.rake @@ -0,0 +1,13 @@ +namespace :sidekiq do + desc "Health check for sidekiq worker process." + task status: [:environment] do + require "socket" + + hostname = Socket.gethostname + if Sidekiq::ProcessSet.new.any? { |ps| ps["hostname"] == hostname } + exit 0 + else + exit 1 + end + end +end diff --git a/lib/tasks/tenants.rake b/lib/tasks/tenants.rake new file mode 100644 index 0000000000..cb4c061e2f --- /dev/null +++ b/lib/tasks/tenants.rake @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +namespace :tenants do + # how much space, works, files, per each tenant? + task calculate_usage: :environment do + @results = [] + Account.where(search_only: false).find_each do |account| + if account.cname.present? + AccountElevator.switch!(account.cname) + puts "---------------#{account.cname}-------------------------" + models = Hyrax.config.curation_concerns.map { |m| "\"#{m}\"" } + works = ActiveFedora::SolrService.query("has_model_ssim:(#{models.join(' OR ')})", rows: 100_000) + if works&.any? + puts "#{works.count} works found" + @tenant_file_sizes = [] + works.each do |work| + document = SolrDocument.find(work.id) + files = document._source["file_set_ids_ssim"] + if files&.any? + file_sizes = [] + files.each do |file| + f = SolrDocument.find(file.to_s) + if file + file_sizes.push(f.to_h['file_size_lts']) unless f.to_h['file_size_lts'].nil? + else + files_sizes.push(0) + end + end + if file_sizes.any? + file_sizes_total_bytes = file_sizes.inject(0, :+) + file_size_total = (file_sizes_total_bytes / 1.0.megabyte).round(2) + else + file_size_total = 0 + end + @tenant_file_sizes.push(file_size_total) + else + @tenant_file_sizes.push(0) + end + end + if @tenant_file_sizes + tenant_file_sizes_total_megabytes = @tenant_file_sizes.inject(0, :+) + @results.push("#{account.cname}: #{tenant_file_sizes_total_megabytes} Total MB / #{works.count} Works") + else + @results.push("#{account.cname}: 0 Total MB / #{works.count} Works") + end + else + @results.push("#{account.cname}: 0 Total MB / 0 Works") + end + puts "==================================================================" + @results.each do |result| + puts result + end + end + end + end +end diff --git a/lib/tasks/workflow_setup.rake b/lib/tasks/workflow_setup.rake new file mode 100644 index 0000000000..b9796fd2c3 --- /dev/null +++ b/lib/tasks/workflow_setup.rake @@ -0,0 +1,6 @@ +namespace :workflow do + desc 'Load new workflow so it is available to existing admin sets' + task setup: :environment do + Hyrax::Workflow::WorkflowImporter.load_workflows + end +end diff --git a/spec/jobs/create_large_derivatives_job_spec.rb b/spec/jobs/create_large_derivatives_job_spec.rb new file mode 100644 index 0000000000..1ed67ef2c0 --- /dev/null +++ b/spec/jobs/create_large_derivatives_job_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.describe CreateLargeDerivativesJob, type: :job do + let(:id) { '123' } + let(:file_set) { FileSet.new } + let(:file) do + Hydra::PCDM::File.new.tap do |f| + f.content = 'foo' + f.original_name = 'video.mp4' + f.save! + end + end + + before do + allow(FileSet).to receive(:find).with(id).and_return(file_set) + allow(file_set).to receive(:id).and_return(id) + # Short-circuit irrelevant logic + allow(file_set).to receive(:reload) + allow(file_set).to receive(:update_index) + end + + it 'runs in the :auxiliary queue' do + expect { described_class.perform_later(file_set, file.id) } + .to have_enqueued_job(described_class) + .on_queue('auxiliary') + end + + # @see CreateDerivativesJobDecorator#perform + it "doesn't schedule itself infinitly" do + expect(described_class).not_to receive(:perform_later) + + described_class.perform_now(file_set, file.id) + end + + it 'successfully calls the logic in CreateDerivativesJob' do + allow(file_set).to receive(:mime_type).and_return('video/mp4') + expect(Hydra::Derivatives::VideoDerivatives).to receive(:create) + + described_class.perform_now(file_set, file.id) + end +end diff --git a/spec/models/sushi/item_report_spec.rb b/spec/models/sushi/item_report_spec.rb new file mode 100644 index 0000000000..76f276ea37 --- /dev/null +++ b/spec/models/sushi/item_report_spec.rb @@ -0,0 +1,251 @@ +# frozen_string_literal:true + +RSpec.describe Sushi::ItemReport do + subject { described_class.new(params, created: created, account: account).as_json } + + let(:account) { double(Account, institution_name: 'Pitt', institution_id_data: {}, cname: 'pitt.hyku.test') } + let(:created) { Time.zone.now } + let(:required_parameters) do + { + begin_date: '2022-01', + end_date: '2023-08' + } + end + + before { create_hyrax_countermetric_objects } + + describe '#as_json' do + let(:params) do + { + **required_parameters, + attributes_to_show: ['Access_Method', 'Fake_Value'] + } + end + + it 'has the expected properties' do + expect(subject).to be_key('Report_Header') + expect(subject.dig('Report_Header', 'Report_Name')).to eq('Item Report') + expect(subject.dig('Report_Header', 'Created')).to eq(created.rfc3339) + expect(subject.dig('Report_Header', 'Report_Attributes', 'Attributes_To_Show')).to eq(['Access_Method']) + expect(subject.dig('Report_Header', 'Report_Filters', 'Begin_Date')).to eq('2022-01-01') + expect(subject.dig('Report_Header', 'Report_Filters', 'End_Date')).to eq('2023-08-31') + expect(subject.dig('Report_Items', 0, 'Items', 0, 'Attribute_Performance', 0, 'Performance', 'Total_Item_Investigations', '2023-08')).to eq(2) + expect(subject.dig('Report_Items', 0, 'Items', 0, 'Attribute_Performance', 0, 'Data_Type')).to eq('Article') + expect(subject.dig('Report_Items', 0, 'Items', 0, 'Attribute_Performance', 0, 'Performance', 'Unique_Item_Investigations', '2023-08')).to eq(1) + expect(subject.dig('Report_Items', 2, 'Items', 0, 'Attribute_Performance', 0, 'Performance', 'Unique_Item_Investigations', '2022-01')).to eq(1) + end + + describe 'yop parameter' do + context 'when provided' do + let(:params) do + { + **required_parameters, + yop: '1900-2023' + } + end + + it 'has a Report_Header > Report_Filters > YOP property' do + expect(subject.dig("Report_Header", "Report_Filters", "YOP")).to eq('1900-2023') + end + end + + context 'when not provided' do + it 'does not have a Report_Header > Report_Filters > YOP property' do + expect(subject.dig("Report_Header", "Report_Filters")).not_to have_key("YOP") + end + end + end + end + + describe 'with an access_method parameter' do + context 'that is fully valid' do + let(:params) do + { + **required_parameters, + access_method: 'regular' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Access_Method')).to eq(['regular']) + expect(subject.dig('Report_Items', 0, 'Items', 0, 'Attribute_Performance', 0, 'Access_Method')).to eq('Regular') + end + end + + context 'that is partially valid' do + let(:params) do + { + **required_parameters, + access_method: 'regular|tdm' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Access_Method')).to eq(['regular']) + expect(subject.dig('Report_Items', 0, 'Items', 0, 'Attribute_Performance', 0, 'Access_Method')).to eq('Regular') + end + end + + context 'that is invalid' do + let(:params) do + { + **required_parameters, + access_method: 'other' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe 'with a yop parameter' do + let(:params) { { yop: "2022", begin_date: '2022-01-03', end_date: '2022-12-31' } } + + # NOTE: We're already bombarding the yop coercion; this is ensuring that the SQL is valid. + it 'filters based on that value' do + expect(subject).to be_a(Hash) + end + end + + describe 'with an item_id parameter' do + context 'that is valid' do + context 'and metrics during the dates specified for that id' do + let(:params) do + { + item_id: '54321', + begin_date: '2022-01-03', + end_date: '2022-04-09' + } + end + + it 'returns one report item' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Item_ID')).to eq('54321') + expect(subject.dig('Report_Items', 0, 'Items', 0, 'Item')).to eq('54321') + end + end + + context 'and no metrics during the dates specified for that id' do + let(:params) do + { + item_id: '54321', + begin_date: '2023-06-05', + end_date: '2023-08-09' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::NoUsageAvailableForRequestedDatesError) + end + end + end + + context 'that is invalid' do + let(:params) do + { + item_id: 'qwerty123', + begin_date: '2022-01-03', + end_date: '2023-08-09' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe 'with an author parameter' do + context 'that is valid' do + let(:params) do + { + **required_parameters, + author: 'Tubman, Harriet' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Author')).to eq('Tubman, Harriet') + end + end + + context 'that is invalid' do + let(:params) do + { + **required_parameters, + author: 'Tubman,Harriet' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe 'with a platform parameter' do + context 'that is valid' do + let(:params) do + { + **required_parameters, + platform: 'pitt.hyku.test' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Platform')).to eq('pitt.hyku.test') + end + end + + context 'that is invalid' do + let(:params) do + { + **required_parameters, + platform: 'another-tenant' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe 'with a metric_type parameter' do + context 'that is valid' do + let(:params) do + { + **required_parameters, + metric_type: 'total_item_investigations' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Metric_Type')).to eq(["Total_Item_Investigations"]) + expect(subject.dig('Report_Items', 0, 'Items', 0, 'Attribute_Performance', 0, 'Performance').length).to eq(1) + end + end + + context 'that is invalid' do + let(:params) do + { + **required_parameters, + metric_type: 'some_other_metric' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe 'with an unrecognized parameter' do + let(:params) { { other: 'nope' } } + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::UnrecognizedParameterError) + end + end +end diff --git a/spec/models/sushi/platform_report_spec.rb b/spec/models/sushi/platform_report_spec.rb new file mode 100644 index 0000000000..af0526e216 --- /dev/null +++ b/spec/models/sushi/platform_report_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal:true + +RSpec.describe Sushi::PlatformReport do + subject { described_class.new(params, created: created, account: account).to_hash } + + let(:account) { double(Account, institution_name: 'Pitt', institution_id_data: {}, cname: 'pitt.hyku.test') } + let(:created) { Time.zone.now } + let(:required_parameters) do + { + begin_date: '2022-01', + end_date: '2022-02' + } + end + + before { create_hyrax_countermetric_objects } + + describe '#as_json' do + context 'with only required params' do + let(:params) { required_parameters } + + it 'has the expected keys' do + expect(subject).to be_key('Report_Header') + expect(subject.dig('Report_Header', 'Created')).to eq(created.rfc3339) + expect(subject.dig('Report_Header', 'Report_Filters', 'Begin_Date')).to eq('2022-01-01') + expect(subject.dig('Report_Header', 'Report_Filters', 'End_Date')).to eq('2022-02-28') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance', 'Total_Item_Investigations', '2022-01')).to eq(6) + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance', 'Total_Item_Requests', '2022-01')).to eq(19) + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance', 'Unique_Item_Investigations', '2022-01')).to eq(3) + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance', 'Unique_Item_Requests', '2022-01')).to eq(3) + expect(subject.dig('Report_Items', 'Attribute_Performance').find { |o| o["Data_Type"] == "Platform" }.dig('Performance', 'Searches_Platform', '2022-01')).to eq(6) + # It should not include that 2021 data. + expect(subject.dig('Report_Items', 'Attribute_Performance', 0, 'Performance', 'Total_Item_Investigations')).to eq("2022-01" => 6) + end + end + + context 'when given metric_types searches_platform AND total_item_requests' do + let(:params) do + { + begin_date: '2023-08', + end_date: '2023-09', + metric_type: 'total_item_requests|searches_platform' + } + end + + it 'includes the platform data type and the article data type (which is the only data type within the date range' do + expect(subject.dig('Report_Items', 'Attribute_Performance').map { |ap| ap['Data_Type'] }.sort).to match_array(['Article', 'Platform']) + end + end + context 'when given a non-platform data_type and omit searches platform' do + let(:params) do + { + begin_date: '2023-08', + end_date: '2023-09', + data_type: 'article', + attributes_to_show: ['Access_Method', 'Fake_Value'], + granularity: 'totals' + } + end + + it 'omits the platform data type' do + expect(subject.dig('Report_Items', 'Attribute_Performance').detect { |dt| dt['Data_Type']['Platform'] }).to be_nil + end + end + + context 'with additional params that are not required' do + let(:params) do + { + begin_date: '2023-08', + end_date: '2023-09', + metric_type: 'total_item_investigations|unique_item_investigations|fake_value', + data_type: 'article', + attributes_to_show: ['Access_Method', 'Fake_Value'], + granularity: 'totals' + } + end + + it "only shows the requested metric types, and does not include metric types that aren't allowed" do + expect(subject.dig('Report_Header', 'Report_Filters', 'Metric_Type')).to eq(['Total_Item_Investigations', 'Unique_Item_Investigations']) + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).to have_key('Total_Item_Investigations') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).to have_key('Unique_Item_Investigations') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).not_to have_key('Total_Item_Requests') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).not_to have_key('Unique_Item_Requests') + end + + it "includes the correct attributes to show, and does not include attributes that aren't allowed" do + expect(subject.dig('Report_Header', 'Report_Attributes', 'Attributes_To_Show')).to eq(['Access_Method']) + end + + it 'does not show title requests/investigations for non-book resource types' do + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).not_to have_key('Unique_Title_Requests') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).not_to have_key('Unique_Title_Investigations') + end + + it 'sums the totals for each metric type' do + expect(subject.dig('Report_Header', 'Report_Attributes', 'Granularity')).to eq('Totals') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance', 'Total_Item_Investigations', 'Totals')).to be_a(Integer) + end + end + end + + describe 'with an access_method parameter' do + context 'that is fully valid' do + let(:params) do + { + **required_parameters, + access_method: 'regular' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Access_Method')).to eq(['regular']) + expect(subject.dig('Report_Items', 'Attribute_Performance', 0, 'Access_Method')).to eq('Regular') + end + end + + context 'that is partially valid' do + let(:params) do + { + **required_parameters, + access_method: 'regular|tdm' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Access_Method')).to eq(['regular']) + expect(subject.dig('Report_Items', 'Attribute_Performance', 0, 'Access_Method')).to eq('Regular') + end + end + + context 'that is invalid' do + let(:params) do + { + **required_parameters, + access_method: 'other' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe 'with a platform parameter' do + context 'that is valid' do + let(:params) do + { + **required_parameters, + platform: 'pitt.hyku.test' + } + end + + it 'returns the item report' do + expect(subject.dig('Report_Header', 'Report_Filters', 'Platform')).to eq('pitt.hyku.test') + end + end + + context 'that is invalid' do + let(:params) do + { + **required_parameters, + platform: 'another-tenant' + } + end + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe 'with an unrecognized parameter' do + let(:params) { { other: 'nope' } } + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::UnrecognizedParameterError) + end + end +end diff --git a/spec/models/sushi/platform_usage_report_spec.rb b/spec/models/sushi/platform_usage_report_spec.rb new file mode 100644 index 0000000000..225b5ea6b7 --- /dev/null +++ b/spec/models/sushi/platform_usage_report_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal:true + +RSpec.describe Sushi::PlatformUsageReport do + let(:account) { double(Account, institution_name: 'Pitt', institution_id_data: {}, cname: "pitt.edu") } + let(:created) { Time.zone.now } + let(:required_parameters) do + { + begin_date: '2022-01', + end_date: '2022-02' + } + end + + describe '#as_json' do + before { create_hyrax_countermetric_objects } + + subject { described_class.new(params, created: created, account: account).as_json } + + context 'with only required params' do + let(:params) { required_parameters } + + it 'has the expected keys' do + expect(subject).to be_key('Report_Header') + expect(subject.dig('Report_Header', 'Created')).to eq(created.rfc3339) + expect(subject.dig('Report_Header', 'Report_Filters', 'Begin_Date')).to eq('2022-01-01') + expect(subject.dig('Report_Header', 'Report_Filters', 'End_Date')).to eq('2022-02-28') + expect(subject.dig('Report_Items', 'Attribute_Performance').find { |o| o["Data_Type"] == "Platform" }.dig('Performance', 'Searches_Platform', '2022-01')).to eq(6) + end + end + + context 'with additional params that are not required' do + let(:params) do + { + **required_parameters, + metric_type: 'total_item_investigations|total_item_requests|fake_value', + access_method: ['Regular'] + } + end + + # Platform usage report should NOT show investigations, even if it is passed in the params. + it "only shows the requested metric types, and does not include metric types that aren't allowed" do + expect(subject.dig('Report_Header', 'Report_Filters', 'Metric_Type')).to eq(['Total_Item_Requests']) + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).to have_key('Total_Item_Requests') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).not_to have_key('Unique_Item_Requests') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).not_to have_key('Total_Item_Investigations') + expect(subject.dig('Report_Items', 'Attribute_Performance').first.dig('Performance')).not_to have_key('Unique_Item_Investigations') + end + end + end + + describe 'with an unrecognized parameter' do + let(:params) { { other: 'nope' } } + + it 'raises an error' do + expect { described_class.new(params, created: created, account: account).as_json }.to raise_error(Sushi::Error::UnrecognizedParameterError) + end + end +end diff --git a/spec/models/sushi/report_list_spec.rb b/spec/models/sushi/report_list_spec.rb new file mode 100644 index 0000000000..466185a3a5 --- /dev/null +++ b/spec/models/sushi/report_list_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +RSpec.describe Sushi::ReportList do + describe '#as_json' do + subject { described_class.new.as_json } + + it 'returns the correct format' do + expect(subject).to be_an_instance_of(Array) + expect(subject.length).to eq(5) + end + + it 'has the expected keys' do + expect(subject.last).to have_key('Report_Name') + expect(subject.last).to have_key('Report_ID') + expect(subject.last).to have_key('Release') + expect(subject.last).to have_key('Report_Description') + expect(subject.last).to have_key('Path') + expect(subject.last).to have_key('First_Month_Available') + expect(subject.last).to have_key('Last_Month_Available') + end + + context 'with data in the Hyrax::CounterMetric table' do + before { create_hyrax_countermetric_objects } + + it 'returns the expected values' do + expect(subject.last['Report_Name']).to eq('Item Report') + expect(subject.last['Report_ID']).to eq('ir') + expect(subject.last['Release']).to eq('5.1') + expect(subject.last['Report_Description']).to eq("This resource returns COUNTER 'Item Master Report' [IR].") + expect(subject.last['Path']).to eq('/api/sushi/r51/reports/ir') + expect(subject.last['First_Month_Available']).to be_a(String) + expect(subject.last['Last_Month_Available']).to be_a(String) + end + end + + context 'without data in the Hyrax::CounterMetric table' do + it 'returns the expected values' do + expect(subject.last['Report_Name']).to eq('Item Report') + expect(subject.last['Report_ID']).to eq('ir') + expect(subject.last['Release']).to eq('5.1') + expect(subject.last['Report_Description']).to eq("This resource returns COUNTER 'Item Master Report' [IR].") + expect(subject.last['Path']).to eq('/api/sushi/r51/reports/ir') + expect(subject.last['First_Month_Available']).to be_nil + expect(subject.last['Last_Month_Available']).to be_nil + end + end + end +end diff --git a/spec/models/sushi/server_status_spec.rb b/spec/models/sushi/server_status_spec.rb new file mode 100644 index 0000000000..7e899ecbb3 --- /dev/null +++ b/spec/models/sushi/server_status_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal:true + +RSpec.describe Sushi::ServerStatus do + describe '#server_status' do + subject { described_class.new(account: account).server_status } + + let(:account) { double(Account, cname: 'pitt.hyku.test') } + + it 'returns the correct format' do + expect(subject).to be_an_instance_of(Array) + expect(subject.length).to eq(1) + end + + it 'has the expected keys' do + expect(subject.first).to have_key('Description') + expect(subject.first).to have_key('Service_Active') + expect(subject.first).to have_key('Registry_Record') + end + + it 'returns the expected values' do + expect(subject.first['Description']).to eq("COUNTER Usage Reports for #{account.cname} platform.") + expect(subject.first['Service_Active']).to eq(true) + expect(subject.first['Registry_Record']).to eq("") + end + end +end diff --git a/spec/models/sushi_spec.rb b/spec/models/sushi_spec.rb new file mode 100644 index 0000000000..835e3d3821 --- /dev/null +++ b/spec/models/sushi_spec.rb @@ -0,0 +1,277 @@ +# frozen_string_literal:true + +RSpec.describe Sushi do + describe '#coerce_to_date' do + subject { described_class.coerce_to_date(given_date) } + + context 'with 2023-02-01' do + let(:given_date) { '2023-02-01' } + + it { is_expected.to eq(Date.new(2023, 2, 1)) } + end + context 'with 2023-05' do + let(:given_date) { '2023-05' } + + it { is_expected.to eq(Date.new(2023, 5, 1)) } + end + context 'with 2023' do + let(:given_date) { '2023' } + + it 'will raise an error' do + expect { subject }.to raise_exception(Sushi::Error::InvalidDateArgumentError) + end + end + end + + describe '.first_month_available' do + subject { described_class.first_month_available } + + let(:entry) do + Hyrax::CounterMetric.create( + worktype: 'GenericWork', + resource_type: 'Book', + work_id: '12345', + date: entry_date, + total_item_investigations: 1, + total_item_requests: 10 + ) + end + + context 'when first entry date is middle of the month and current date is end of this month' do + let(:current_date) { Time.zone.today.end_of_month } + let(:entry_date) { 10.days.ago(current_date) } + + before { entry } + + it 'raises an error' do + expect { subject }.to raise_error(Sushi::Error::UsageNotReadyForRequestedDatesError) + end + end + + context 'when first entry is the middle of two months ago and current date is middle of this month' do + let(:current_date) { 10.days.ago(Time.zone.today.end_of_month) } + let(:entry_date) { 10.days.ago(2.months.ago(Time.zone.today.end_of_month)) } + + before { entry } + + it 'will return the beginning of the month of the earliest entry' do + expect(subject).to eq(entry_date.beginning_of_month) + end + end + + context 'when there are no entries' do + it 'raises an error' do + expect { subject }.to raise_error(Sushi::Error::UsageNotReadyForRequestedDatesError) + end + end + end + + describe '.last_month_available' do + subject { described_class.last_month_available } + + let(:entry) do + Hyrax::CounterMetric.create( + worktype: 'GenericWork', + resource_type: 'Book', + work_id: '12345', + date: entry_date, + total_item_investigations: 1, + total_item_requests: 10 + ) + end + let(:create_older_entry) do + # Because sometimes we nee + Hyrax::CounterMetric.create( + worktype: 'GenericWork', + resource_type: 'Book', + work_id: '12345', + date: 2.years.ago, + total_item_investigations: 1, + total_item_requests: 10 + ) + end + + context 'when we have one month of data and the current date is within that month' do + let(:current_date) { Time.zone.today.end_of_month } + let(:entry_date) { 5.days.ago(Time.zone.today.end_of_month) } + + before { entry } + + it 'raises an error' do + expect { subject }.to raise_error(Sushi::Error::UsageNotReadyForRequestedDatesError) + end + end + + context 'when last entry date is middle of the month and current date is end of this month' do + let(:current_date) { Time.zone.today.end_of_month } + let(:entry_date) { 10.days.ago(current_date) } + + before do + create_older_entry + entry + end + it 'will use the end of the previous current month' do + expect(subject).to eq(1.month.ago(current_date).end_of_month) + end + end + + context 'when last entry is the middle of two months ago and current date is middle of this month' do + let(:current_date) { 10.days.ago(Time.zone.today.end_of_month) } + let(:entry_date) { 10.days.ago(2.months.ago(Time.zone.today.end_of_month)) } + + before do + create_older_entry + entry + end + + it 'will return the end of the month of the last entry' do + expect(subject).to eq(entry_date.end_of_month) + end + end + + context 'when last entry date is beginning of the month and current entry date is beginning of the month' do + let(:current_date) { Time.zone.today.beginning_of_month } + let(:entry_date) { Time.zone.today.beginning_of_month } + + before do + create_older_entry + entry + end + + it 'will return the end of the month prior to the last entry' do + expect(subject).to eq(1.month.ago(current_date).end_of_month) + end + end + + context 'when there are no entries' do + it 'raises an error' do + expect { subject }.to raise_error(Sushi::Error::UsageNotReadyForRequestedDatesError) + end + end + end + + describe '#validate_date_format' do + context 'when the begin_date or end_date are in MM-YYYY format' do + let(:dates) { ['06-2023', '08-2023'] } + + it 'raises an error' do + expect { subject.validate_date_format(dates) }.to raise_error(Sushi::Error::InvalidDateArgumentError) + end + end + + context 'when the begin_date or end_date are in YYYY-MM-DD format' do + let(:dates) { ['2023-06-05', '2023-08-09'] } + + before { Sushi.info = [] } + + it 'stores the exception' do + expect(Sushi.info).to be_empty + subject.validate_date_format(dates) + expect(Sushi.info).not_to be_empty + expect(Sushi.info.first).to be_a(Hash) + end + end + end + + describe "AuthorCoercion" do + describe '.deserialize' do + subject { Sushi::AuthorCoercion.deserialize(string) } + + [ + ["|Hello|World|", ["Hello", "World"]], + ["|Hello|", ["Hello"]], + ["||", []], + ["", []], + [nil, []] + ].each do |given_authors, expected_array| + context "with #{given_authors.inspect}" do + let(:string) { given_authors } + + it { is_expected.to match_array(expected_array) } + end + end + end + + describe '.serialize' do + subject { Sushi::AuthorCoercion.serialize(array) } + + [ + [["Hello", "World"], "|Hello|World|"], + [["Hello"], "|Hello|"], + [[], nil] + ].each do |given_authors, expected| + context "with #{given_authors.inspect}" do + let(:array) { given_authors } + + it { is_expected.to eq(expected) } + end + end + end + + describe '#coerce_author' do + let(:klass) do + Class.new do + include Sushi::AuthorCoercion + def initialize(params = {}) + coerce_author(params) + end + end + end + + it "raises a 3060 error when given a #{Sushi::AuthorCoercion::DELIMITER} character" do + expect { klass.new(author: "Hello#{Sushi::AuthorCoercion::DELIMITER}World") }.to raise_error(Sushi::Error::InvalidReportFilterValueError) + end + end + end + + describe "YearOfPublicationCoercion" do + subject(:instance) { klass.new(params) } + + let(:klass) do + Class.new do + include Sushi::YearOfPublicationCoercion + def initialize(params = {}) + coerce_yop(params) + end + end + end + + let(:params) { {} } + + it { is_expected.to respond_to(:yop_as_where_parameters) } + + context 'when no :yop is provided' do + its(:yop_as_where_parameters) { is_expected.to be_falsey } + end + + # rubocop:disable Metrics/LineLength + [ + ['2003', ["((year_of_publication = ?))", 2003]], + ['2003-2005', ["((year_of_publication >= ? AND year_of_publication <= ?))", 2003, 2005]], + ['2003a-2005', Sushi::Error::InvalidDateArgumentError], + ['-1', ["((year_of_publication = ?))", -1]], + ['a-1', Sushi::Error::InvalidDateArgumentError], + ['a1', Sushi::Error::InvalidDateArgumentError], + ['1-2 3', Sushi::Error::InvalidDateArgumentError], + ['1 2', Sushi::Error::InvalidDateArgumentError], + ['1996-1994', ["((year_of_publication >= ? AND year_of_publication <= ?))", 1996, 1994]], + ['1996-1994 | 1999-2003|9a', Sushi::Error::InvalidDateArgumentError], + ['1996-1994 | 1299-2003|9-12', ["((year_of_publication >= ? AND year_of_publication <= ?) OR (year_of_publication >= ? AND year_of_publication <= ?) OR (year_of_publication >= ? AND year_of_publication <= ?))", 1996, 1994, 1299, 2003, 9, 12]], + ['1994-1996 | 1989', ["((year_of_publication >= ? AND year_of_publication <= ?) OR (year_of_publication = ?))", 1994, 1996, 1989]] + ].each do |given_yop, expected| + context "when given :yop parameter is #{given_yop.inspect}" do + let(:params) { { yop: given_yop } } + + if expected.is_a?(Array) + its(:yop) { is_expected.to eq(given_yop) } + its(:yop_as_where_parameters) { is_expected.to match_array(expected) } + else + it "raises an #{expected}" do + expect { subject }.to raise_error(expected) + end + end + end + end + # rubocop:enable Metrics/LineLength + end +end diff --git a/spec/requests/api/sushi_spec.rb b/spec/requests/api/sushi_spec.rb new file mode 100644 index 0000000000..1bccfec4e2 --- /dev/null +++ b/spec/requests/api/sushi_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +RSpec.describe 'api/sushi/r51', type: :request, singletenant: true do + before { create_hyrax_countermetric_objects } + let(:required_parameters) do + { + begin_date: '2022-01', + end_date: '2022-02' + } + end + + RSpec.shared_examples 'without required parameters' do |endpoint| + it 'returns a 422 unprocessable entity' do + get "/api/sushi/r51/reports/#{endpoint}" + expect(response).to have_http_status(422) + end + end + + describe 'GET /api/sushi/r51/reports/ir' do + it_behaves_like 'without required parameters', 'ir' + + it 'returns a 200 with correct response for item report' do + get '/api/sushi/r51/reports/ir', params: required_parameters + expect(response).to have_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body.dig('Report_Header', 'Report_Name')).to eq('Item Report') + end + + context 'with a valid item_id parameter' do + it 'returns a 200 status report for the given item' do + get '/api/sushi/r51/reports/ir', params: { **required_parameters, item_id: '54321' } + expect(response).to have_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body['Report_Items']).to be_instance_of(Array) + end + end + + context 'with an invalid item_id parameter' do + it 'returns a 422 status report for the given item' do + get '/api/sushi/r51/reports/ir', params: { **required_parameters, item_id: 'qwerty123' } + expect(response).to have_http_status(422) + parsed_body = JSON.parse(response.body) + + expect(parsed_body['Code']).to eq(3060) + end + end + end + + describe 'GET /api/sushi/r51/reports/pr (e.g. platform report)' do + it_behaves_like 'without required parameters', 'pr' + + it 'returns a 200 status' do + get '/api/sushi/r51/reports/pr', params: required_parameters + expect(response).to have_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body.dig('Report_Header', 'Report_Name')).to eq('Platform Report') + end + end + + describe 'GET /api/sushi/r51/reports/pr_p1 (e.g. platform usage report)' do + it_behaves_like 'without required parameters', 'pr_p1' + + it 'returns a 200 status' do + get '/api/sushi/r51/reports/pr_p1', params: required_parameters + expect(response).to have_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body.dig('Report_Header', 'Report_Name')).to eq('Platform Usage') + end + end + + describe 'GET /api/sushi/r51/status (e.g. platform status)' do + it 'returns a 200 status' do + get '/api/sushi/r51/status' + expect(response).to have_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body.dig(0, 'Description')).to include('COUNTER Usage Reports') + end + end + + describe 'GET /api/sushi/r51/reports (e.g. reports list)' do + it 'returns a 200 status' do + get '/api/sushi/r51/reports' + expect(response).to have_http_status(200) + parsed_body = JSON.parse(response.body) + expect(parsed_body.first).to have_key('Report_Name') + expect(parsed_body.first).to have_key('Report_ID') + expect(parsed_body.first).to have_key('Release') + expect(parsed_body.first).to have_key('Report_Description') + expect(parsed_body.first).to have_key('Path') + expect(parsed_body.last).to have_key('First_Month_Available') + expect(parsed_body.last).to have_key('Last_Month_Available') + end + end +end diff --git a/spec/services/import_counter_metrics_spec.rb b/spec/services/import_counter_metrics_spec.rb new file mode 100644 index 0000000000..3d1052f6d9 --- /dev/null +++ b/spec/services/import_counter_metrics_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe ImportCounterMetrics do + # TODO: Write more robust tests for this service + it 'creates Hyrax::CounterMetrics with investigations' do + ImportCounterMetrics.import_investigations('spec/fixtures/csv/pittir-views.csv') + expect(Hyrax::CounterMetric.count).not_to be_nil + end + + it 'creates Hyrax::CounterMetrics with requests' do + ImportCounterMetrics.import_requests('spec/fixtures/csv/pittir-downloads.csv') + expect(Hyrax::CounterMetric.count).not_to be_nil + end +end