diff --git a/app/controllers/api/v0/statistics_controller.rb b/app/controllers/api/v0/statistics_controller.rb index b3b95a4..5567512 100644 --- a/app/controllers/api/v0/statistics_controller.rb +++ b/app/controllers/api/v0/statistics_controller.rb @@ -3,23 +3,47 @@ class StatisticsController < Api::V0::BaseController before_action :authenticate - ## - # GET - # @return a count of users who joined DMPonline between the optional specified dates - # users are scoped to the organisation of the user initiating the call + # GET /api/v0/statistics/users_joined?start_date=&end_date=&org_id= + # Returns the number of users joined for the user's org. + # If start_date is passed, only counts those with created_at is >= than start_date + # If end_date is passed, only counts those with created_at is <= than end_date are + # If org_id is passed and user has super_admin privileges that counter is performed against org_id param instead of user's org + # @return def users_joined raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, :statistics).users_joined? - users = restrict_date_range(@user.org.users) - confirmed_users = [] - users.each do |user| - unless user.confirmed_at.blank? - confirmed_users += [user] - end - end - @users_count = confirmed_users.count - respond_with @users_count - end + scoped = User.unscoped.where.not(confirmed_at: nil) + if @user.can_super_admin? && params[:org_id].present? + scoped = scoped.where(org_id: params[:org_id]) + else + scoped = scoped.where(org_id: @user.org_id) + end + + if params[:range_dates].present? + r = {} + params[:range_dates].each_pair do |k, v| + r[k] = scoped + .where('created_at >=?', v['start_date']) + .where('created_at <=?', v['end_date']).count + end + render(json: r.to_json) + else + scoped = scoped.where('created_at >= ?', Date.parse(params[:start_date])) if params[:start_date].present? + scoped = scoped.where('created_at <= ?', Date.parse(params[:end_date])) if params[:end_date].present? + @users_count = scoped.count + respond_with @users_count + end + end + # GET + # Returns the number of completed plans within the user's org for the data start_date and end_date specified + def completed_plans + # raise Pundit::NotAuthorizedError unless Api::V0::StatisticsPolicy.new(@user, :statistics).completed_plans? + # users = User.unscoped.where(org_id: org) + # roles = Role.where(access: Role.access_values_for(:creator, :administrator, :editor, :commenter).min) + # plan_ids = Plan.select(:id).joins(:roles).merge(roles.joins(:user).merge(users)) + # Plan.joins(:questions).where("plans.id": plan_ids).group("plans.id").count("questions.id") plan_id followed by number of questions + # Plan.joins(:answers).where("plans.id": plan_ids).group("plans.id").count("answers.id") plan_id followed by numbers of answers + end ## # GET diff --git a/app/controllers/usage_controller.rb b/app/controllers/usage_controller.rb new file mode 100644 index 0000000..08b9299 --- /dev/null +++ b/app/controllers/usage_controller.rb @@ -0,0 +1,7 @@ +class UsageController < ApplicationController + # GET /usage + def index + raise Pundit::NotAuthorizedError unless current_user.present? && (current_user.can_org_admin? || current_user.can_super_admin?) + render('index', locals: { orgs: Org.all }) + end +end \ No newline at end of file diff --git a/app/policies/api/v0/statistics_policy.rb b/app/policies/api/v0/statistics_policy.rb index 155a3da..44ed714 100644 --- a/app/policies/api/v0/statistics_policy.rb +++ b/app/policies/api/v0/statistics_policy.rb @@ -18,6 +18,9 @@ true end + def completed_plans? + true + end ## # need to check if your org owns this template def using_template? diff --git a/app/views/layouts/_branding.html.erb b/app/views/layouts/_branding.html.erb index e262438..7d89c63 100644 --- a/app/views/layouts/_branding.html.erb +++ b/app/views/layouts/_branding.html.erb @@ -96,6 +96,11 @@ <%= link_to(_('Themes'), super_admin_themes_path) %> <% end %> + <% if current_user.can_org_admin? || current_user.can_super_admin? %> +
  • > + <%= link_to(_('Usage'), usage_index_path) %> +
  • + <% end %> <% end %> diff --git a/app/views/usage/index.html.erb b/app/views/usage/index.html.erb new file mode 100644 index 0000000..665886c --- /dev/null +++ b/app/views/usage/index.html.erb @@ -0,0 +1,87 @@ +
    +
    +

    Usage statistics

    +
    +
    +
    +
    +

    <%= _('No. users joined during last year') %>

    + +
    +
    +
    +
    +
    +

    <%= _('Run your own filter') %>

    + <% if current_user.api_token.present? %> +
    + <%= hidden_field_tag('api_token', current_user.api_token) %> +
    +
    +
    + <%= label_tag('topic', _('Topic')) %> + <%= select_tag('topic', options_for_select( + [ + [_('Users'), 'users', { 'data-url': users_joined_api_v0_statistics_path }], + [_('Completed Plans'), 'completed_plans', { 'data-url': '#' }] + ]), class: 'form-control') %> +
    +
    +
    +
    + <%= label_tag('start_date', _('Start date')) %> + <%= date_field_tag('start_date', nil, class: 'form-control') %> +
    +
    +
    +
    + <%= label_tag('end_date', _('End date')) %> + <%= date_field_tag('end_date', nil, class: 'form-control') %> +
    +
    + <% if current_user.can_super_admin? %> +
    +
    + <%= label_tag(:org_id, _('Organisation')) %> + <%= select_tag(:org_id, options_from_collection_for_select(orgs, :id, :name, current_user.org_id), class: 'form-control') %> +
    +
    + <% else %> + <%= hidden_field_tag(:org_id, current_user.org_id) %> + <% end %> +
    + <%= submit_tag(_('Go'), class: 'btn btn-default') %> +
    +
    +
    + <% else %> +

    + <%= _('You don\'t have access to use the API. An api token is needed to generate usage statistics.') %> +

    + <% end %> +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    <%= _('New users') %>

    + +
    +
    +
    +
    +
    +
    +

    <%= _('Total users') %>

    + +
    +
    +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index f477585..6e9684d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -218,6 +218,8 @@ end end + resources :usage, only: [:index] + resources :roles, only: [:create, :update, :destroy] do member do put :deactivate diff --git a/lib/assets/javascripts/application.js b/lib/assets/javascripts/application.js index ad89292..f5e2ac2 100644 --- a/lib/assets/javascripts/application.js +++ b/lib/assets/javascripts/application.js @@ -45,5 +45,6 @@ import './views/shared/my_org'; import './views/shared/sign_in_form'; import './views/super_admin/themes/new_edit'; +import './views/usage/index'; import './views/users/notification_preferences'; import './views/users/admin_grant_permissions'; diff --git a/lib/assets/javascripts/views/usage/index.js b/lib/assets/javascripts/views/usage/index.js new file mode 100644 index 0000000..c38673e --- /dev/null +++ b/lib/assets/javascripts/views/usage/index.js @@ -0,0 +1,79 @@ +import moment from 'moment/moment'; +import Chart from 'chart.js'; + +$(() => { + const usageFormSelector = '.usage_index'; + const apiToken = $(usageFormSelector).find('input[name="api_token"]').val(); + // Builds an object whose keys are the topic fro the select options and value its the value + // associated to the attribute data-url of each option + const topicToURL = $(`${usageFormSelector} select[name="topic"]`).find('option').map((i, el) => { + const topic = $(el); + return { [topic.val()]: $(el).attr('data-url') }; + }).get() // An array of objects { topic: URL } + .reduce((acc, value) => Object.assign(acc, value), {}); // Flatten to a single object + // Events + $(usageFormSelector).on('submit', (e) => { + e.preventDefault(); + const target = $(e.target); + const topic = target.find('select[name="topic"]').val(); + const orgId = target.find('select[name="org_id"]').val() || target.find('input[name="org_id"]').val(); + $('[data-topic]').hide(); // Hides any data-topic view + const ajaxSettings = ({ totals = false } = {}) => ({ + headers: { Authorization: `Token token="${apiToken}"` }, + url: topicToURL[topic], + data: totals ? { topic, org_id: orgId } : target.serialize(), + }); + $.when($.ajax(ajaxSettings()), $.ajax(ajaxSettings({ totals: true }))).then((r1, r2) => { + const view = $(`[data-topic="${topic}"]`); + if (topic === 'users') { + view.find('[data-range]').html(r1[0].users_joined); + view.find('[data-totals]').html(r2[0].users_joined); + view.show(); + } + }); // TODO request error handling + }); + const rangeDatesUpToLastYearFromNow = () => { + const getLastMonth = () => moment().subtract(1, 'month').clone(); + const rangeDates = new Array(12).fill(1).reduce((acc, v, i) => { + const id = getLastMonth().subtract(i, 'month').format('MMM-YY'); + acc[id] = { + start_date: getLastMonth().startOf('month').subtract(i, 'month').format('YYYY-MM-DD'), + end_date: getLastMonth().endOf('month').subtract(i, 'month').format('YYYY-MM-DD'), + id }; + return acc; + }, {}); + + return rangeDates; + }; + const initialise = () => { + $.ajax({ + headers: { Authorization: `Token token="${apiToken}"` }, + url: topicToURL.users, + data: { range_dates: rangeDatesUpToLastYearFromNow() }, + }).then((data) => { + new Chart($('#yearly_users'), { // eslint-disable-line no-new + type: 'bar', + data: { + labels: Object.keys(data), + datasets: [{ + data: Object.keys(data).map(k => data[k]), + backgroundColor: '#4F5253', // TODO parameterised according to roadmap main colour instance + }], + }, + options: { + legend: { + display: false, + }, + tooltips: { + callbacks: { + label: tooltipItem => `${tooltipItem.yLabel} users`, + }, + }, + }, + }); + }, (jqXHR) => { + console.log('error: %o', jqXHR); + }); + }; + initialise(); +}); diff --git a/lib/assets/package.json b/lib/assets/package.json index 213eb91..36c3c16 100644 --- a/lib/assets/package.json +++ b/lib/assets/package.json @@ -20,12 +20,14 @@ "homepage": "https://github.com/DMPRoadmap/roadmap#readme", "dependencies": { "bootstrap-sass": "^3.3.7", + "chart.js": "^2.7.1", "font-awesome": "^4.7.0", "jquery": "3.2.1", "jquery-accessible-autocomplete-list-aria": "1.5.5", "jquery-ui-dist": "1.12.1", "jquery-ujs": "1.2.2", "js-cookie": "2.1.4", + "moment": "^2.20.1", "number-to-text": "^0.3.2", "placeholder": "1.0.2", "timeago.js": "^3.0.2",