diff --git a/app/controllers/api/v2/usage_reports_controller.rb b/app/controllers/api/v2/usage_reports_controller.rb new file mode 100644 index 0000000000..6bd13a007c --- /dev/null +++ b/app/controllers/api/v2/usage_reports_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +# API endpoint for generating usage reports +class Api::V2::UsageReportsController < ActionController::Base + def create + authorize! :create, UsageReport + @export = UsageReportService.build(export_params, current_user) + @export.mark_started! + kpi_params = { 'start_date' => export_params[:selected_from_date], 'end_date' => export_params[:selected_to_date] } + UsageReportService.enqueue(@export, export_params[:password], kpi_params) + render :create + end + + private + + def export_params + @export_params ||= params.require(:data).permit( + :record_type, :export_format, + :order, :query, :file_name, :password, :selected_from_date, :selected_to_date, + { custom_export_params: {} }, { filters: {} }, :match_criteria + ) + end +end diff --git a/app/javascript/components/action-dialog/component.jsx b/app/javascript/components/action-dialog/component.jsx index 9a4904e65f..ddd5015150 100644 --- a/app/javascript/components/action-dialog/component.jsx +++ b/app/javascript/components/action-dialog/component.jsx @@ -41,7 +41,8 @@ function ActionDialog({ successHandler, fetchAction, fetchArgs = [], - fetchLoadingPath + fetchLoadingPath, + fullWidth }) { const dispatch = useDispatch(); @@ -137,7 +138,7 @@ function ActionDialog({ -
+
{renderField}
@@ -59,6 +58,10 @@ function FormSectionField({ checkErrors, field, formMethods, formMode, disableUn FormSectionField.displayName = "FormSectionField"; +FormSectionField.defaultProps = { + disableUnderline: false +}; + FormSectionField.propTypes = { checkErrors: PropTypes.object, disableUnderline: PropTypes.bool, diff --git a/app/javascript/components/form/records.js b/app/javascript/components/form/records.js index 1cf74343e9..f278f38192 100644 --- a/app/javascript/components/form/records.js +++ b/app/javascript/components/form/records.js @@ -91,7 +91,8 @@ export const FieldRecord = Record({ type: "", visible: null, watchedInputs: null, - wrapWithComponent: null + wrapWithComponent: null, + allowFullWidth: true }); export const FormSectionRecord = Record({ diff --git a/app/javascript/components/form/use-form-field.js b/app/javascript/components/form/use-form-field.js index f766be87a1..ec804dd796 100644 --- a/app/javascript/components/form/use-form-field.js +++ b/app/javascript/components/form/use-form-field.js @@ -1,5 +1,4 @@ // Copyright (c) 2014 - 2023 UNICEF. All rights reserved. - import get from "lodash/get"; import { useMemo } from "react"; @@ -104,7 +103,8 @@ export default (field, { checkErrors, errors, formMode, disableUnderline }) => { showDeleteAction, showDisableOption, maxOptionsAllowed, - optionFieldName + optionFieldName, + allowFullWidth } = field; const i18n = useI18n(); @@ -151,7 +151,7 @@ export default (field, { checkErrors, errors, formMode, disableUnderline }) => { disabled: inputDisabled, error: inputError, format: dateFormat, - fullWidth: true, + fullWidth: allowFullWidth !== undefined ? allowFullWidth : true, helperText: inputHelperTxt, label: inputLabel, name, diff --git a/app/javascript/components/form/utils/form-submission.js b/app/javascript/components/form/utils/form-submission.js index b665fd3ec1..807da00eef 100644 --- a/app/javascript/components/form/utils/form-submission.js +++ b/app/javascript/components/form/utils/form-submission.js @@ -16,17 +16,18 @@ export const submitHandler = ({ onSubmit, submitAllFields, submitAllArrayData = false, - message = null, + message, submitAlways }) => { // formState needs to be called here otherwise touched will not work. // https://github.com/react-hook-form/react-hook-form-website/issues/154 const changedFormData = touchedFormData(dirtyFields, data, isEdit, initialValues, submitAllArrayData); + const snackbarMessage = message !== undefined ? message : null; if (isEmpty(changedFormData) && !submitAlways) { return dispatch( - enqueueSnackbar(message, { - ...(!message && { messageKey: "messages.no_changes" }), + enqueueSnackbar(snackbarMessage, { + ...(!snackbarMessage && { messageKey: "messages.no_changes" }), type: "error" }) ); diff --git a/app/javascript/components/pages/admin/index.js b/app/javascript/components/pages/admin/index.js index 4fb615e75f..f8e8d42b0f 100644 --- a/app/javascript/components/pages/admin/index.js +++ b/app/javascript/components/pages/admin/index.js @@ -19,3 +19,4 @@ export { default as ConfigurationsList } from "./configurations-list"; export { default as ConfigurationsForm } from "./configurations-form"; export { default as LocationsList } from "./locations-list"; export { default as CodeOfConduct } from "./code-of-conduct"; +export { default as UsageReports } from "./usage-reports"; diff --git a/app/javascript/components/pages/admin/index.unit.test.js b/app/javascript/components/pages/admin/index.unit.test.js index 3ab3d3049a..325f1afc50 100644 --- a/app/javascript/components/pages/admin/index.unit.test.js +++ b/app/javascript/components/pages/admin/index.unit.test.js @@ -25,7 +25,8 @@ describe("pages/admin - index", () => { "UserGroupsForm", "UserGroupsList", "UsersForm", - "UsersList" + "UsersList", + "UsageReports" ].forEach(property => { expect(indexValues).to.have.property(property); delete indexValues[property]; diff --git a/app/javascript/components/pages/admin/roles-form/constants.js b/app/javascript/components/pages/admin/roles-form/constants.js index 44243b4483..3374109eb4 100644 --- a/app/javascript/components/pages/admin/roles-form/constants.js +++ b/app/javascript/components/pages/admin/roles-form/constants.js @@ -38,7 +38,8 @@ export const RESOURCES = [ "activity_log", "matching_configuration", "duplicate", - "kpi" + "kpi", + "usage_report" ]; export const ROLES_PERMISSIONS = Object.freeze({ diff --git a/app/javascript/components/pages/admin/usage-reports/constants.js b/app/javascript/components/pages/admin/usage-reports/constants.js new file mode 100644 index 0000000000..6e90c4c299 --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/constants.js @@ -0,0 +1,3 @@ +const NAME = "UsageReports"; + +export default NAME; diff --git a/app/javascript/components/pages/admin/usage-reports/container.jsx b/app/javascript/components/pages/admin/usage-reports/container.jsx new file mode 100644 index 0000000000..df3d190a13 --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/container.jsx @@ -0,0 +1,41 @@ +// Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +import { SwapVert } from "@mui/icons-material"; + +import { useI18n } from "../../../i18n"; +import { PageHeading, PageContent } from "../../../page"; +import { useDialog } from "../../../action-dialog"; +import { FormAction } from "../../../form"; + +import UsageReport from "./export/component"; +import { USAGE_REPORT_DIALOG } from "./export/constants"; + +function Container() { + const i18n = useI18n(); + const { setDialog, pending, dialogOpen, setDialogPending, dialogClose } = useDialog(USAGE_REPORT_DIALOG); + const handleExport = dialog => setDialog({ dialog, open: true }); + const handleClickExport = () => handleExport(USAGE_REPORT_DIALOG); + + return ( + <> + + } /> + + + + + + ); +} + +Container.displayName = "UsageReports"; + +Container.propTypes = {}; + +export default Container; diff --git a/app/javascript/components/pages/admin/usage-reports/export/component.jsx b/app/javascript/components/pages/admin/usage-reports/export/component.jsx new file mode 100644 index 0000000000..ca223d3532 --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/export/component.jsx @@ -0,0 +1,112 @@ +// Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +import PropTypes from "prop-types"; +import { useDispatch } from "react-redux"; +import { object, string, date } from "yup"; + +import ActionDialog from "../../../../action-dialog"; +import Form from "../../../../form"; +import { saveUsageExport } from "../../../../record-actions/exports/action-creators"; +import { formatFileName } from "../../../../record-actions/exports/utils"; +import { toServerDateFormat } from "../../../../../libs"; + +import { NAME, FORM_ID } from "./constants"; +import { form } from "./form"; +import css from "./style.css"; + +function Component({ close, i18n, open, pending, setPending }) { + const dispatch = useDispatch(); + const dialogPending = typeof pending === "object" ? pending.get("pending") : pending; + const message = undefined; + + const validationSchema = object({ + fromDate: date() + .transform( + originalValue => (originalValue === "" ? null : originalValue) // transform empty string to null + ) + .required("From Date is required") + .typeError("From Date must be a valid date"), + toDate: date() + .transform( + originalValue => (originalValue === "" ? null : originalValue) // transform empty string to null + ) + .required("To Date is required") + .typeError("To Date must be a valid date") // catch invalid date format + .test({ + name: "is-after", + message: "To Date must be greater than From Date", + test: (value, context) => { + const { fromDate } = context.parent; + + return !fromDate || !value || value > fromDate; + } + }), + file_name: string().required("File name is required") + }); + + // Form submission + const onSubmit = getData => { + const fileName = formatFileName(getData.file_name, "xlsx"); + const defaultBody = { + export_format: "xlsx", + record_type: "usage_report", + file_name: fileName, + selected_from_date: toServerDateFormat(getData.fromDate), + selected_to_date: toServerDateFormat(getData.toDate) + }; + const data = { ...defaultBody }; + + setPending(true); + dispatch( + saveUsageExport( + { data }, + i18n.t(message || "exports.queueing", { + file_name: fileName ? `: ${fileName}.` : "." + }), + i18n.t("exports.go_to_exports") + ) + ); + }; + + const handleCustomClose = () => { + close(); + }; + + return ( + +
+ + ); +} + +Component.displayName = NAME; + +Component.propTypes = { + close: PropTypes.func, + i18n: PropTypes.object, + open: PropTypes.bool, + pending: PropTypes.bool, + setPending: PropTypes.func +}; + +export default Component; diff --git a/app/javascript/components/pages/admin/usage-reports/export/constants.js b/app/javascript/components/pages/admin/usage-reports/export/constants.js new file mode 100644 index 0000000000..0b76f61672 --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/export/constants.js @@ -0,0 +1,4 @@ +export const NAME = "UsageReport"; +export const USAGE_REPORT_DIALOG = "UsageReportDialog"; +export const EXPORT_TYPES = Object.freeze({ EXCEL: "xlsx" }); +export const FORM_ID = "export-users-form"; diff --git a/app/javascript/components/pages/admin/usage-reports/export/form.js b/app/javascript/components/pages/admin/usage-reports/export/form.js new file mode 100644 index 0000000000..fbff038dc6 --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/export/form.js @@ -0,0 +1,32 @@ +/* eslint-disable import/prefer-default-export */ + +import { fromJS } from "immutable"; + +import { FieldRecord, FormSectionRecord, TEXT_FIELD, DATE_FIELD } from "../../../../form"; + +export const form = i18n => { + return fromJS([ + FormSectionRecord({ + unique_id: "import_locations", + fields: [ + FieldRecord({ + display_name: `${i18n.t("key_performance_indicators.date_range_dialog.from")}*`, + name: "fromDate", + type: DATE_FIELD, + allowFullWidth: false + }), + FieldRecord({ + display_name: `${i18n.t("key_performance_indicators.date_range_dialog.to")}*`, + name: "toDate", + type: DATE_FIELD, + allowFullWidth: false + }), + FieldRecord({ + name: "file_name", + display_name: `${i18n.t("usage_report.file_name")}*`, + type: TEXT_FIELD + }) + ] + }) + ]); +}; diff --git a/app/javascript/components/pages/admin/usage-reports/export/index.js b/app/javascript/components/pages/admin/usage-reports/export/index.js new file mode 100644 index 0000000000..b6e0586481 --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/export/index.js @@ -0,0 +1 @@ +export { default } from "./component"; diff --git a/app/javascript/components/pages/admin/usage-reports/export/style.css b/app/javascript/components/pages/admin/usage-reports/export/style.css new file mode 100644 index 0000000000..6ae042c91b --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/export/style.css @@ -0,0 +1,18 @@ +.usage-reports-form { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.usage-reports-form>div:nth-child(3) { + grid-column: span 2; +} + + + + + + + + + diff --git a/app/javascript/components/pages/admin/usage-reports/index.js b/app/javascript/components/pages/admin/usage-reports/index.js new file mode 100644 index 0000000000..3d520d78d5 --- /dev/null +++ b/app/javascript/components/pages/admin/usage-reports/index.js @@ -0,0 +1 @@ +export { default } from "./container"; diff --git a/app/javascript/components/permissions/constants.js b/app/javascript/components/permissions/constants.js index e2160bbc3a..e1f2968241 100644 --- a/app/javascript/components/permissions/constants.js +++ b/app/javascript/components/permissions/constants.js @@ -149,6 +149,7 @@ export const RESOURCES = { roles: "roles", systems: "systems", tracing_requests: "tracing_requests", + usage_reports: "usage_reports", user_groups: "user_groups", users: "users", webhooks: "webhooks" @@ -166,7 +167,8 @@ export const ADMIN_RESOURCES = [ RESOURCES.forms, RESOURCES.metadata, RESOURCES.audit_logs, - RESOURCES.webhooks + RESOURCES.webhooks, + RESOURCES.usage_reports ]; export const SEARCH_OTHERS = [...MANAGE, ACTIONS.SEARCH_OWNED_BY_OTHERS]; @@ -227,6 +229,8 @@ export const REFER_FROM_SERVICE = [...MANAGE, ACTIONS.REFERRAL, ACTIONS.REFERRAL export const ACTIVITY_LOGS = [...MANAGE, ACTIONS.TRANSFER]; +export const SHOW_USAGE_REPORTS = [...MANAGE, ACTIONS.READ]; + export const REQUEST_APPROVAL = [ ...MANAGE, ACTIONS.REQUEST_APPROVAL_ASSESSMENT, diff --git a/app/javascript/components/permissions/constants.unit.test.js b/app/javascript/components/permissions/constants.unit.test.js index 56627f4719..cff4107f92 100644 --- a/app/javascript/components/permissions/constants.unit.test.js +++ b/app/javascript/components/permissions/constants.unit.test.js @@ -162,6 +162,7 @@ describe("Verifying config constant", () => { "roles", "systems", "tracing_requests", + "usage_reports", "user_groups", "users", "webhooks" diff --git a/app/javascript/components/permissions/index.js b/app/javascript/components/permissions/index.js index 2b06ff994b..1ebc2c238a 100644 --- a/app/javascript/components/permissions/index.js +++ b/app/javascript/components/permissions/index.js @@ -52,7 +52,8 @@ export { VIEW_KPIS, WRITE_RECORDS, WRITE_REGISTRY_RECORD, - REMOVE_ALERT + REMOVE_ALERT, + SHOW_USAGE_REPORTS } from "./constants"; export { checkPermissions } from "./utils"; diff --git a/app/javascript/components/record-actions/exports/action-creators.js b/app/javascript/components/record-actions/exports/action-creators.js index 49760ef0bd..310e7364e5 100644 --- a/app/javascript/components/record-actions/exports/action-creators.js +++ b/app/javascript/components/record-actions/exports/action-creators.js @@ -31,3 +31,29 @@ export const saveExport = (body, message, actionLabel) => ({ ] } }); + +export const saveUsageExport = (body, message, actionLabel) => ({ + type: actions.EXPORT, + api: { + path: "usage_reports", + method: "POST", + body, + successCallback: [ + { + action: ENQUEUE_SNACKBAR, + payload: { + message, + options: { + variant: "success", + key: generate.messageKey(message) + }, + actionLabel, + actionUrl: "/usage_reports" + } + }, + { + action: CLEAR_DIALOG + } + ] + } +}); diff --git a/app/javascript/components/record-actions/exports/action-creators.unit.test.js b/app/javascript/components/record-actions/exports/action-creators.unit.test.js index 4775f0ea8a..113114fe8e 100644 --- a/app/javascript/components/record-actions/exports/action-creators.unit.test.js +++ b/app/javascript/components/record-actions/exports/action-creators.unit.test.js @@ -16,7 +16,7 @@ describe(" - exports/action-creators", () => { it("should have known action creators", () => { const creators = { ...actionCreators }; - ["saveExport"].forEach(property => { + ["saveExport", "saveUsageExport"].forEach(property => { expect(creators).to.have.property(property); delete creators[property]; }); diff --git a/app/javascript/config.js b/app/javascript/config.js index 8cec2a2f22..5985521c74 100644 --- a/app/javascript/config.js +++ b/app/javascript/config.js @@ -19,7 +19,8 @@ import { SHOW_SUMMARY, READ_MANAGED_REPORTS, READ_REGISTRY_RECORD, - READ_FAMILY_RECORD + READ_FAMILY_RECORD, + SHOW_USAGE_REPORTS } from "./components/permissions/constants"; import getAdminResources from "./components/pages/admin/utils/get-admin-resources"; @@ -137,6 +138,7 @@ const RECORD_PATH = { users: "users", activity_log: "activity_log", registry_records: "registry_records", + usage_reports: "usage_reports", webpush_config: "webpush/config" }; @@ -215,7 +217,8 @@ const ROUTES = { password_reset_request: "/password_reset_request", registry_records: "/registry_records", subscriptions: "/webpush/subscriptions", - subscriptions_current: "/webpush/subscriptions/current" + subscriptions_current: "/webpush/subscriptions/current", + usage_reports: "/admin/usage_reports" }; const PERMITTED_URL = [ @@ -353,6 +356,12 @@ const ADMIN_NAV = [ label: "settings.navigation.audit_logs", permission: SHOW_AUDIT_LOGS, recordType: RESOURCES.audit_logs + }, + { + to: "/usage_reports", + label: "settings.navigation.usage_reports", + permission: SHOW_USAGE_REPORTS, + recordType: RESOURCES.usage_reports } ]; diff --git a/app/javascript/routes.js b/app/javascript/routes.js index 6d0c94bfea..c2708edb7c 100644 --- a/app/javascript/routes.js +++ b/app/javascript/routes.js @@ -62,6 +62,7 @@ import Login, { IdpLogin } from "./components/login"; import Logout from "./components/logout"; import PasswordResetRequest from "./components/login/components/password-reset-form"; import { ROUTES, MODES, RECORD_PATH } from "./config"; +import UsageReports from "./components/pages/admin/usage-reports"; const recordPaths = [ RECORD_PATH.cases, @@ -291,6 +292,11 @@ export default [ resources: RESOURCES.users, actions: ADMIN_ACTIONS }, + { + path: ROUTES.usage_reports, + component: UsageReports, + resources: RESOURCES.usage_reports + }, { path: `${ROUTES.admin_user_groups}/new`, component: UserGroupsForm, diff --git a/app/jobs/usage_report_job.rb b/app/jobs/usage_report_job.rb new file mode 100644 index 0000000000..e2a831e959 --- /dev/null +++ b/app/jobs/usage_report_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +# Executes a UsageReportJob +class UsageReportJob < ApplicationJob + queue_as :usage_report_export + + def perform(bulk_export_id, encrypted_password, kpi_parameters) + bulk_export = BulkExport.find_by(id: bulk_export_id) + password = EncryptionService.decrypt(encrypted_password) + return log_bulk_export_missing(bulk_export_id) unless bulk_export.present? + + bulk_export.usage_export(password, kpi_parameters) + rescue Errors::MisconfiguredEncryptionError => e + log_encryption_error(bulk_export_id, e) + end + + def log_bulk_export_missing(id) + Rails.logger.warn("BulkExport Id: #{id} was not found. Skipping...") + end + + def log_encryption_error(id, error) + Rails.logger.error( + "BulkExport Id: #{id} could not be enqueued because the password could not be decrypted!" \ + "\n#{error.backtrace}" + ) + end +end diff --git a/app/models/bulk_export.rb b/app/models/bulk_export.rb index 83e9758b69..9d679af488 100644 --- a/app/models/bulk_export.rb +++ b/app/models/bulk_export.rb @@ -5,6 +5,7 @@ # Represents the asynchronous run of a queued export job. # In Primero v2, all exports are asynchronous. # See app/models/exporters +# rubocop:disable Metrics/ClassLength class BulkExport < ApplicationRecord PROCESSING = 'job.status.processing' # The job is still running TERMINATED = 'job.status.terminated' # The job terminated due to an error @@ -15,17 +16,15 @@ class BulkExport < ApplicationRecord EXPIRES = 60.seconds # Expiry for the delegated ActiveStorage url alias_attribute :export_format, :format + attr_accessor :selected_from_date, :selected_to_date scope :owned, ->(owner_user_name) { where(owned_by: owner_user_name) } - belongs_to :owner, class_name: 'User', foreign_key: 'owned_by', primary_key: 'user_name' has_one_attached :export_file - validates :owned_by, presence: true validates :record_type, presence: true validates :format, presence: true validates :export_file, file_size: { less_than_or_equal_to: 50.megabytes }, if: -> { export_file.attached? } - before_save :generate_file_name def self.validate_password!(password) @@ -40,12 +39,31 @@ def self.api_path def export(password) process_records_in_batches(500) { |records_batch| exporter.export(records_batch) } + zip_export(exporter, password) + end + + def usage_export(password, kpi_parameters) + usage_exporter.export(kpi_parameters['start_date'], kpi_parameters['end_date'], kpi_parameters['request']) + zip_export(usage_exporter, password) + end + + def zip_export(exporter, password) exporter.complete zipped_file = ZipService.zip(stored_file_name, password) attach_export_file(zipped_file) mark_completed! end + def usage_exporter + return @usage_exporter if @usage_exporter.present? + + @usage_exporter = Exporters::UsageReportExporter.new( + stored_file_name, + { record_type:, user: owner }, + custom_export_params&.with_indifferent_access || {} + ) + end + def model_class @model_class ||= PrimeroModelService.to_model(record_type) end @@ -152,3 +170,4 @@ def attach_export_file(file) File.delete(file) end end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/exporters/usage_report_exporter.rb b/app/models/exporters/usage_report_exporter.rb new file mode 100644 index 0000000000..59af5012b3 --- /dev/null +++ b/app/models/exporters/usage_report_exporter.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +# Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +# Class to export UsageReport +# rubocop:disable Metrics/ClassLength +class Exporters::UsageReportExporter < Exporters::ExcelExporter + attr_accessor :file_name, :workbook, :errors, :worksheets + + class << self + def id + 'xlsx' + end + + def supported_models + [User] + end + end + + def initialize(output_file_path = nil, config = {}, options = {}) + super(output_file_path, config, options) + self.workbook = WriteXLSX.new(buffer) + self.worksheets = {} + self.locale = user&.locale || I18n.locale + end + + def export(start_date, end_date, request) + export_user_row(start_date, end_date, request) + modules = UsageReportService.modules + modules.each { |modul| export_modules(modul, start_date, end_date) } + end + + def complete + @workbook.close + buffer + end + + private + + def export_modules(modul, start_date, end_date) + return if modul.blank? || modul.instance_of?(ActiveRecord::Relation) + + export_module_to_workbook(modul, start_date, end_date) + end + + def export_module_to_workbook(modul, start_date, end_date) + worksheet = workbook.add_worksheet(modul.name) + adjust_column_width(worksheet) + worksheet.write(0, 0, module_header(modul.name)) + worksheet.write(1, 0, module_content(modul.unique_id, start_date, end_date, modul.name)) + end + + def module_header(modul_name) + common_keys = [modul_name, 'Total Cases', 'Open Cases', 'Closed Cases', 'Open this quarter', + 'Closed this Quarter', 'Total Services', 'Total incidents', 'Incidents this quarter'] + case modul_name + when 'MRM' + [modul_name, 'Total incidents', 'Incidents this quarter'] + when 'GBV' + common_keys + else + common_keys + ['Total followups'] + end + end + + def get_common_keys(module_id, start_date, end_date) + ['', UsageReportService.get_total_records(module_id, Child), UsageReportService.get_open_cases(module_id), + UsageReportService.get_closed_cases(module_id), + UsageReportService.get_new_records_quarter(module_id, start_date, end_date, Child), + UsageReportService.get_closed_cases_quarter(module_id, start_date, end_date), + UsageReportService.get_total_services(module_id), UsageReportService.get_total_records(module_id, Incident), + UsageReportService.get_new_records_quarter(module_id, start_date, end_date, Incident)] + end + + def module_content(module_id, start_date, end_date, modul_name) + common_keys = get_common_keys(module_id, start_date, end_date) + case modul_name + when 'MRM' + ['', UsageReportService.get_total_records(module_id, Incident), + UsageReportService.get_new_records_quarter(module_id, start_date, end_date, Incident)] + when 'GBV' + common_keys + else + common_keys + [UsageReportService.get_total_followup(module_id)] + end + end + + def kpi_user_header(start_date, end_date, request, worksheet) + worksheet.write(0, 0, user_url_header(request)) + worksheet.write(1, 0, user_start_date_header(start_date)) + worksheet.write(2, 0, user_end_date_header(end_date)) + worksheet.write(3, 0, quarter_for_date(end_date)) + worksheet.write(4, 0, total_agencies) + end + + def export_user_row(start_date, end_date, request) + worksheet = workbook.add_worksheet('Users') + adjust_column_width(worksheet) + kpi_user_header(start_date, end_date, request, worksheet) + row_indx = 6 + UsageReportService.modules.each do |modul| + worksheet.write(row_indx, 0, module_tabs(modul.unique_id, modul.name)) + row_indx += 1 + end + worksheet.write(10, 0, user_header) + worksheet.write(11, 0, user_content(start_date, end_date)) + end + + def quarter_for_date(end_date) + ['Quarter', determine_quarter(end_date)] + end + + def determine_quarter(end_date) + case end_date.month + when 1..3 + 'Q1' + when 4..6 + 'Q2' + when 7..9 + 'Q3' + else + 'Q4' + end + end + + def user_url_header(request) + ['Url', request] + end + + def user_start_date_header(start_date) + ['Start Date', start_date] + end + + def user_end_date_header(end_date) + ['End Date', end_date] + end + + def total_agencies + ['Total No. of Agencies', UsageReportService.all_agencies.count] + end + + def module_tabs(module_id, modul_name) + if modul_name == 'MRM' + [modul_name, UsageReportService.get_total_records(module_id, Incident).positive? ? ' Yes' : ' No'] + else + [modul_name, UsageReportService.get_total_records(module_id, Child).positive? ? ' Yes' : ' No'] + end + end + + def user_header + ['Agency List', 'Total Users', 'Active Users', 'Disabled Users', 'New Users in this quarter'] + end + + def user_content(start_date, end_date) + data = UsageReportService.all_agencies.map do |agency| + [agency.unique_id, + UsageReportService.get_all_users(agency), + UsageReportService.get_active_users(agency), + UsageReportService.get_disabled_users(agency), + UsageReportService.get_new_quarter_users(agency, start_date, end_date)] + end + data.transpose + end + + def adjust_column_width(worksheet) + ('A'..'L').each_with_index do |_, col_index| + worksheet.set_column(col_index, col_index, 20) # Adjust column width + end + end +end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/permission.rb b/app/models/permission.rb index 1822224f42..346befd2a0 100644 --- a/app/models/permission.rb +++ b/app/models/permission.rb @@ -53,6 +53,7 @@ class Permission < ValueObject REPORT = 'report' MANAGED_REPORT = 'managed_report' AUDIT_LOG = 'audit_log' + USAGE_REPORT = 'usage_report' MATCHING_CONFIGURATION = 'matching_configuration' SELF = 'self' # A redundant permission. This is implied. GROUP = 'group' @@ -247,6 +248,7 @@ class Permission < ValueObject DASH_VIOLATIONS_CATEGORY_REGION, DASH_PERPETRATOR_ARMED_FORCE_GROUP_PARTY_NAMES ], AUDIT_LOG => [READ], + USAGE_REPORT => [READ], MATCHING_CONFIGURATION => [MANAGE], KPI => [ READ, KPI_ASSESSMENT_STATUS, KPI_AVERAGE_FOLLOWUP_MEETINGS_PER_CASE, @@ -316,7 +318,7 @@ def actions def resources [CASE, INCIDENT, TRACING_REQUEST, POTENTIAL_MATCH, REGISTRY_RECORD, ROLE, USER, USER_GROUP, AGENCY, WEBHOOK, - METADATA, SYSTEM, REPORT, MANAGED_REPORT, DASHBOARD, AUDIT_LOG, MATCHING_CONFIGURATION, DUPLICATE, + METADATA, SYSTEM, REPORT, MANAGED_REPORT, DASHBOARD, AUDIT_LOG, USAGE_REPORT, MATCHING_CONFIGURATION, DUPLICATE, CODE_OF_CONDUCT, ACTIVITY_LOG] end diff --git a/app/models/usage_report.rb b/app/models/usage_report.rb new file mode 100644 index 0000000000..41a85aec0b --- /dev/null +++ b/app/models/usage_report.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +# class for Usage Report +class UsageReport < BulkExport + def self.export(kpi_parameters) + exporter.export(kpi_parameters['start_date'], kpi_parameters['end_date'], kpi_parameters['request']) + exporter.complete + mark_completed! + end + + def exporter + return @exporter if @exporter.present? + + @exporter = Exporters::UsageReportExporter.new( + stored_file_name, + { record_type:, user: owner }, + custom_export_params&.with_indifferent_access || {} + ) + end + + def stored_file_name + return unless file_name.present? + + File.join(Rails.configuration.exports_directory, "#{id}_#{file_name}") + end +end diff --git a/app/services/usage_report_service.rb b/app/services/usage_report_service.rb new file mode 100644 index 0000000000..30c95db77a --- /dev/null +++ b/app/services/usage_report_service.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +# Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +# Service class for Usage Report +class UsageReportService + class << self + def build(params, user) + return unless params[:export_format] && params[:record_type] + + params_hash = DestringifyService.destringify(params.except(:password).to_h, true) + export = BulkExport.new(params_hash) + export.owner = user + export + end + + def enqueue(bulk_export, password = nil, kpi_parameters = nil) + encrypted_password = EncryptionService.encrypt(password) + UsageReportJob.perform_later(bulk_export.id, encrypted_password, kpi_parameters) + end + + def user_agencies(agency_id) + User.joins(:agency).where(agencies: { unique_id: agency_id }) + end + + def get_all_users(agency) + user_agencies(agency.unique_id).count + end + + def get_active_users(agency) + user_agencies(agency.unique_id).where(disabled: false).count + end + + def get_disabled_users(agency) + user_agencies(agency.unique_id).where(disabled: true).count + end + + def get_new_quarter_users(agency, start_date, end_date) + user_agencies(agency.unique_id).where('DATE(users.created_at) BETWEEN ? AND ?', start_date, end_date).count + end + + def all_agencies + Agency.all + end + + def modules + PrimeroModule.all + end + + def get_total_records(module_id, recordtype) + recordtype.where("data->>'module_id' = ?", module_id).count + end + + def get_open_cases(module_id) + Child.where("data->>'module_id' = ? AND data->>'status' = ?", module_id, 'open').count + end + + def get_closed_cases(module_id) + Child.where("data->>'module_id' = ? AND data->>'status' = ?", module_id, 'closed').count + end + + def get_new_records_quarter(module_id, start_date, end_date, recordtype) + recordtype.where( + "data->>'module_id' = ? AND data->>'created_at' BETWEEN ? AND ?", + module_id, start_date, end_date + ).count + end + + def get_closed_cases_quarter(module_id, start_date, end_date) + Child.where( + "data->>'module_id' = ? AND data->>'date_closure' BETWEEN ? AND ?", + module_id, start_date, end_date + ).count + end + + def get_total_services(module_id) + query = <<~SQL + SELECT COUNT(*) + FROM ( + SELECT jsonb_array_elements(data->'services_section') AS service_entry + FROM cases + WHERE data->>'module_id' = ? + ) subquery + SQL + + ActiveRecord::Base.connection.exec_query(ActiveRecord::Base.send(:sanitize_sql_array, [query, module_id])) + .rows.flatten.first.to_i + end + + def get_total_followup(module_id) + query = <<~SQL + SELECT COUNT(*) + FROM ( + SELECT jsonb_array_elements(data->'followup_subform_section') AS followup_entry + FROM cases + WHERE data->>'module_id' = ? + ) subquery + SQL + + ActiveRecord::Base.connection.exec_query(ActiveRecord::Base.send(:sanitize_sql_array, [query, module_id])) + .rows.flatten.first.to_i + end + end +end diff --git a/app/views/api/v2/usage_reports/create.json.jbuilder b/app/views/api/v2/usage_reports/create.json.jbuilder new file mode 100644 index 0000000000..0d46560807 --- /dev/null +++ b/app/views/api/v2/usage_reports/create.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Copyright (c) 2014 - 2023 UNICEF. All rights reserved. + +# app/views/usage_reports/create.json.builder + +json.message 'Usage report export has been initiated successfully.' diff --git a/config/routes.rb b/config/routes.rb index 1b73c4e2cb..135c8df510 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -107,6 +107,7 @@ get :'refer-to', to: 'users_transitions#refer_to' post :'password-reset-request', to: 'password_reset#password_reset_request' post :'password-reset', to: 'password_reset#password_reset' + get :export, to: 'users#export' end end resources :identity_providers, only: [:index] @@ -125,6 +126,7 @@ end resources :bulk_exports, as: :exports, path: :exports, only: %i[index show create destroy] get 'alerts', to: 'alerts#bulk_index' + resources :usage_reports, only: [:create] resources :agencies resources :webhooks resources :roles diff --git a/db/configuration/users/roles.rb b/db/configuration/users/roles.rb index 33259d7f83..9edf214dc3 100644 --- a/db/configuration/users/roles.rb +++ b/db/configuration/users/roles.rb @@ -897,6 +897,10 @@ def create_or_update_role(role_hash) resource: Permission::AUDIT_LOG, actions: [Permission::MANAGE] ), + Permission.new( + resource: Permission::USAGE_REPORT, + actions: [Permission::MANAGE] + ), Permission.new( resource: Permission::MATCHING_CONFIGURATION, actions: [Permission::MANAGE] diff --git a/spec/models/permission_spec.rb b/spec/models/permission_spec.rb index 085b6b2f4d..ad21b5ac16 100644 --- a/spec/models/permission_spec.rb +++ b/spec/models/permission_spec.rb @@ -16,7 +16,7 @@ Permission::AGENCY, Permission::WEBHOOK, Permission::METADATA, Permission::SYSTEM, Permission::REPORT, Permission::MANAGED_REPORT, Permission::DASHBOARD, Permission::AUDIT_LOG, Permission::MATCHING_CONFIGURATION, Permission::DUPLICATE, Permission::CODE_OF_CONDUCT, - Permission::ACTIVITY_LOG] + Permission::ACTIVITY_LOG, Permission::USAGE_REPORT] expect(Permission.resources).to match_array(expected) end end