diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index cc42075..d8e5aad 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -4,6 +4,7 @@ # Look for template overrides before rendering before_filter :prepend_view_paths + include GlobalHelpers include Pundit helper_method GlobalHelpers.instance_methods diff --git a/app/controllers/paginable/notifications_controller.rb b/app/controllers/paginable/notifications_controller.rb new file mode 100644 index 0000000..8cda8df --- /dev/null +++ b/app/controllers/paginable/notifications_controller.rb @@ -0,0 +1,10 @@ +module Paginable + class NotificationsController < ApplicationController + include Paginable + # /paginable/notifications/index/:page + def index + authorize(Notification) + paginable_renderise(partial: 'index', scope: Notification.all) + end + end +end diff --git a/app/controllers/super_admin/notifications_controller.rb b/app/controllers/super_admin/notifications_controller.rb new file mode 100644 index 0000000..575c786 --- /dev/null +++ b/app/controllers/super_admin/notifications_controller.rb @@ -0,0 +1,97 @@ +module SuperAdmin + class NotificationsController < ApplicationController + before_action :set_notification, only: %i[show edit update destroy acknowledge] + before_action :set_notifications, only: :index + + helper PaginableHelper + + # GET /notifications + # GET /notifications.json + def index + authorize(Notification) + render(:index, locals: { notifications: @notifications.page(1) }) + end + + # GET /notifications/new + def new + authorize(Notification) + @notification = Notification.new + end + + # GET /notifications/1/edit + def edit + authorize(Notification) + end + + # POST /notifications + # POST /notifications.json + def create + authorize(Notification) + begin + n = Notification.new(notification_params) + n.notification_type = 'global' + n.save! + flash[:notice] = _('Notification created successfully') + rescue ActionController::ParameterMissing + flash[:alert] = _('Unable to save since notification parameter is missing') + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = e.message + end + redirect_to action: :index + end + + # PATCH/PUT /notifications/1 + # PATCH/PUT /notifications/1.json + def update + authorize(Notification) + begin + @notification.update!(notification_params) + flash[:notice] = _('Notification updated successfully') + rescue ActionController::ParameterMissing + flash[:alert] = _('Unable to save since notification parameter is missing') + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = e.message + end + redirect_to action: :index + end + + # DELETE /notifications/1 + # DELETE /notifications/1.json + def destroy + authorize(Notification) + begin + @notification.destroy + flash[:notice] = _('Successfully destroyed your notification') + rescue ActiveRecord::RecordNotDestroyed + flash[:alert] = _('The theme with id %{id} could not be destroyed') % { id: params[:id] } + end + redirect_to action: :index + end + + # GET /notifications/1/acknowledge + def acknowledge + @notification.acknowledge + render nothing: true + end + + private + + # Use callbacks to share common setup or constraints between actions. + def set_notification + @notification = Notification.find(params[:id] || params[:notification_id]) + rescue ActiveRecord::RecordNotFound + flash[:alert] = _('There is no notification associated with id %{id}') % { id: params[:id] } + redirect_to action: :index + end + + # Use callbacks to share common setup or constraints between actions. + def set_notifications + @notifications = Notification.all + end + + # Never trust parameters from the scary internet, only allow the white list through. + def notification_params + params.require(:notification).permit(:title, :level, :body, :dismissable, :starts_at, :expires_at) + end + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 5da6539..5376a69 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -24,7 +24,7 @@ def admin_grant_permissions user = User.find(params[:id]) authorize user - + # Super admin can grant any Perm, org admins can only grant Perms they # themselves have access to if current_user.can_super_admin? @@ -36,8 +36,8 @@ render json: { "user" => { "id" => user.id, - "html" => render_to_string(partial: 'users/admin_grant_permissions', - locals: { user: user, perms: perms }, + "html" => render_to_string(partial: 'users/admin_grant_permissions', + locals: { user: user, perms: perms }, formats: [:html]) } }.to_json @@ -127,7 +127,7 @@ redirect_to request.referer, alert: _('Unknown organisation.') end end - + # PUT /users/:id/activate # ----------------------------------------------------- def activate @@ -139,23 +139,31 @@ user.active = !user.active user.save! render json: { - code: 1, + code: 1, msg: _('Successfully %{action} %{username}\'s account.') % { action: user.active ? _('activated') : _('deactivated'), username: user.name(false) } } rescue Exception - render json: { - code: 0, + render json: { + code: 0, msg: _('Unable to %{action} %{username}') % { action: user.active ? _('activate') : _('deactivate'), username: user.name(false) } } end end end - + + # POST /users/acknowledge_notification + def acknowledge_notification + authorize current_user + @notification = Notification.find(params[:notification_id]) + current_user.acknowledge(@notification) + render nothing: true + end + private def org_swap_params params.require(:user).permit(:org_id, :org_name) end - + ## # html forms return our boolean values as strings, this converts them to true/false def booleanize_hash(node) diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb new file mode 100644 index 0000000..9ee9a66 --- /dev/null +++ b/app/helpers/notifications_helper.rb @@ -0,0 +1,14 @@ +module NotificationsHelper + # Return FA html class depending on Notification level + # @return [String] Font Awesome HTML class + def fa_classes(notification) + case notification.level + when 'warning' + 'fa-exclamation-circle' + when 'danger' + 'fa-times-circle' + else + 'fa-info-circle' + end + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb new file mode 100644 index 0000000..c320f01 --- /dev/null +++ b/app/models/notification.rb @@ -0,0 +1,40 @@ +class Notification < ActiveRecord::Base + enum level: %i[info warning danger] + enum notification_type: %i[global] + + has_and_belongs_to_many :users, dependent: :destroy, join_table: 'notification_acknowledgements' + + validates :notification_type, :title, :level, :starts_at, :expires_at, :body, presence: true + validate :valid_dates + + scope :active, (lambda do + where('starts_at <= :now and :now < expires_at', now: Time.now) + end) + + scope :active_per_user, (lambda do |user| + if user.present? + acknowledgement_ids = user.notifications.map(&:id) + active.where.not(id: acknowledgement_ids) + else + active.where(dismissable: false) + end + end) + + # Has the Notification been acknowledged by the given user ? + # If no user is given, currently logged in user (if any) is the default + # @return [Boolean] is the Notification acknowledged ? + def acknowledged?(user) + users.include?(user) if user.present? && dismissable? + end + + # Validate Notification dates + def valid_dates + return false if starts_at.blank? || expires_at.blank? + errors.add(:starts_at, _('Should be today or later')) if starts_at < Date.today + errors.add(:expires_at, _('Should be tomorrow or later')) if expires_at < Date.tomorrow + if starts_at > expires_at + errors.add(:starts_at, _('Should be before expiration date')) + errors.add(:expires_at, _('Should be after start date')) + end + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 0bee5c5..619c714 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -41,6 +41,7 @@ has_many :user_identifiers has_many :identifier_schemes, through: :user_identifiers + has_and_belongs_to_many :notifications, dependent: :destroy, join_table: 'notification_acknowledgements' validates :email, email: true, allow_nil: true, uniqueness: {message: _("must be unique")} @@ -49,7 +50,7 @@ default_scope { includes(:org, :perms) } # Retrieves all of the org_admins for the specified org - scope :org_admins, -> (org_id) { + scope :org_admins, -> (org_id) { joins(:perms).where("users.org_id = ? AND perms.name IN (?) AND users.active = ?", org_id, ['grant_permissions', 'modify_templates', 'modify_guidance', 'change_org_details'], true) } @@ -261,9 +262,9 @@ if self.pref.present? existing = self.pref.settings[key.to_s].deep_symbolize_keys - - # Check for new preferences - defaults.keys.each do |grp| + + # Check for new preferences + defaults.keys.each do |grp| defaults[grp].keys.each do |pref, v| # If the group isn't present in the saved values add all of it's preferences existing[grp] = defaults[grp] if existing[grp].nil? @@ -292,6 +293,12 @@ User.where("lower(#{field}) = ?", val.respond_to?(:downcase) ? val.downcase : val.to_s) end + # Acknoledge a Notification + # @param notification Notification to acknowledge + def acknowledge(notification) + notifications << notification if notification.dismissable? + end + private def when_org_changes if org_id != org_id_was diff --git a/app/policies/notification_policy.rb b/app/policies/notification_policy.rb new file mode 100644 index 0000000..8d60c3c --- /dev/null +++ b/app/policies/notification_policy.rb @@ -0,0 +1,30 @@ +class NotificationPolicy < ApplicationPolicy + def initialize(user, *_args) + raise Pundit::NotAuthorizedError, _('must be logged in') unless user + @user = user + end + + def index? + @user.can_super_admin? + end + + def new? + @user.can_super_admin? + end + + def create? + @user.can_super_admin? + end + + def edit? + @user.can_super_admin? + end + + def update? + @user.can_super_admin? + end + + def destroy? + @user.can_super_admin? + end +end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index f9f6f47..e55a870 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -27,23 +27,26 @@ def org_swap? user.can_super_admin? end - + def activate? user.can_super_admin? end - + def edit? user.can_super_admin? end - + def update? user.can_super_admin? end - + class Scope < Scope def resolve scope.where(org_id: user.org_id) end end -end \ No newline at end of file + def acknowledge_notification? + true + end +end diff --git a/app/views/layouts/_branding.html.erb b/app/views/layouts/_branding.html.erb index 7d89c63..a8ccc51 100644 --- a/app/views/layouts/_branding.html.erb +++ b/app/views/layouts/_branding.html.erb @@ -101,6 +101,11 @@ <%= link_to(_('Usage'), usage_index_path) %> <% end %> + <% if current_user.can_super_admin? %> +
  • > + <%= link_to _('Notifications'), super_admin_notifications_path %> +
  • + <% end %> <% end %> diff --git a/app/views/layouts/_notifications.html.erb b/app/views/layouts/_notifications.html.erb new file mode 100644 index 0000000..b74a5c3 --- /dev/null +++ b/app/views/layouts/_notifications.html.erb @@ -0,0 +1,14 @@ +<% notifications.each do |a| %> + <% unless a.acknowledged?(current_user) %> +
    + + <%= "#{a.level.capitalize}:" %> + <%= raw a.body %> + <% if a.dismissable? %> + + <% end %> +
    + <% end %> +<% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 801fba4..bb8e5d6 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -35,7 +35,7 @@ <%= stylesheet_link_tag fingerprinted_asset('application') %> <%= javascript_include_tag fingerprinted_asset('vendor') %> - <%= javascript_include_tag fingerprinted_asset('application') %> + <%= javascript_include_tag fingerprinted_asset('application') %> <%= csrf_meta_tags %> @@ -63,6 +63,7 @@ <%= has_alert ? _('Error:') : _('Notice:') %> <%= raw (has_alert ? alert : notice) %> + <%= render "layouts/notifications", notifications: Notification.active_per_user(current_user) %> <%= yield %> @@ -71,7 +72,7 @@ <%= render "layouts/footer" %> - <% + <% constants_json = { PASSWORD_MIN_LENGTH: 8, PASSWORD_MAX_LENGTH: 128, @@ -92,7 +93,7 @@ SHOW_PASSWORD_MESSAGE: _('Show password'), SHOW_SELECT_ORG_MESSAGE: _('Select an organisation from the list.'), SHOW_OTHER_ORG_MESSAGE: _('My organisation isn\'t listed'), - + PLAN_VISIBILITY_WHEN_TEST: _('N/A'), PLAN_VISIBILITY_WHEN_NOT_TEST: _('Private'), PLAN_VISIBILITY_WHEN_NOT_TEST_TOOLTIP: _('Private: restricted to me and people I invite.'), diff --git a/app/views/paginable/notifications/_index.html.erb b/app/views/paginable/notifications/_index.html.erb new file mode 100644 index 0000000..324e720 --- /dev/null +++ b/app/views/paginable/notifications/_index.html.erb @@ -0,0 +1,20 @@ + + + + + + + + + + + <% scope.each do |notification| %> + + + + + + + <% end %> + +
    <%= _('Title') %> <%= paginable_sort_link('title') %><%= _('Level') %> <%= paginable_sort_link('level') %><%= _('Start') %> <%= paginable_sort_link('starts_at') %><%= _('Expiration') %> <%= paginable_sort_link('expires_at') %>
    <%= link_to notification.title, edit_super_admin_notification_path(notification) %><%= notification.level %><%= notification.starts_at %><%= notification.expires_at %>
    diff --git a/app/views/super_admin/notifications/_form.html.erb b/app/views/super_admin/notifications/_form.html.erb new file mode 100644 index 0000000..b161b86 --- /dev/null +++ b/app/views/super_admin/notifications/_form.html.erb @@ -0,0 +1,66 @@ +<% url = @notification.new_record? ? super_admin_notifications_path : super_admin_notification_path(@notification) %> + +<%= form_for @notification, url: url, html: { class: 'notification' } do |f| %> +
    +
    + <%= f.label :title, _('Title'), class: 'control-label' %> + <%= f.text_field :title, + class: 'form-control', + value: @notification.title, + "aria-required": true %> +
    + +
    + <%= f.label :level, _('Level'), class: 'control-label' %> + <%= f.select :level, + Notification.levels.keys.map { |l| [l.humanize, l] }, + { value: @notification.level }, + { class: 'form-control', + data: { toggle: 'tooltip', html: true }, title: _('Info: Simple information message, displayed in blue.
    Warning: warning message, for signaling something unusual, displayed in orange.
    Danger: error message, for anything critical, displayed in red') } %> +
    +
    + +
    +
    + <%= f.check_box :dismissable, style: 'width: auto' %> + <%= f.label :dismissable, _('Dismissable'), class: 'control-label' %> +
    +
    + +
    +
    + <%= f.label :starts_at, _('Start'), class: 'control-label' %> + <%= f.date_field :starts_at, + class: 'form-control', + value: (@notification.starts_at || Date.today), + min: Date.today %> +
    + +
    + <%= f.label :expires_at, _('Expiration'), class: 'control-label' %> + <%= f.date_field :expires_at, + class: 'form-control', + value: (@notification.expires_at || Date.tomorrow), + min: Date.tomorrow %> +
    +
    + +
    + <%= f.label :body, _('Body'), class: 'control-label' %> + <%= f.text_area :body, + class: 'form-control', + value: @notification.body, + "aria-required": true %> +
    + +
    + <%= f.button _('Save'), class: 'btn btn-default', type: 'submit' %> + <%= link_to( + _('Delete'), + super_admin_notification_path(@notification), + class: 'btn btn-default', + method: :delete, + data: { confirm: _('Are you sure you want to delete the notification "%{title}"') % { title: @notification.title }}) unless @notification.new_record? %> + <%= link_to _('Cancel'), super_admin_notifications_path, class: 'btn btn-default', role: 'button' %> +
    +<% end %> diff --git a/app/views/super_admin/notifications/edit.html.erb b/app/views/super_admin/notifications/edit.html.erb new file mode 100644 index 0000000..558afe0 --- /dev/null +++ b/app/views/super_admin/notifications/edit.html.erb @@ -0,0 +1,3 @@ +

    <%= _('Editing Notification') %>

    + +<%= render 'form' %> diff --git a/app/views/super_admin/notifications/index.html.erb b/app/views/super_admin/notifications/index.html.erb new file mode 100644 index 0000000..381b93e --- /dev/null +++ b/app/views/super_admin/notifications/index.html.erb @@ -0,0 +1,21 @@ +
    +
    +

    <%= _('Notifications') %>

    +
    +
    +
    +
    +
    + <%= link_to _('+ Add New Notification'), new_super_admin_notification_path, class: 'btn btn-default', role: 'button' %> +
    +
    + <%= paginable_renderise( + partial: '/paginable/notifications/index', + controller: 'paginable/notifications', + action: 'index', + scope: notifications, + query_params: { sort_field: 'notifications.starts_at', sort_direction: 'desc' }) + %> +
    +
    +
    diff --git a/app/views/super_admin/notifications/new.html.erb b/app/views/super_admin/notifications/new.html.erb new file mode 100644 index 0000000..0e4b494 --- /dev/null +++ b/app/views/super_admin/notifications/new.html.erb @@ -0,0 +1,3 @@ +

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

    + +<%= render 'form' %> diff --git a/config/routes.rb b/config/routes.rb index 5e2376b..19a1333 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,7 @@ put 'update_email_preferences' put 'org_swap', constraints: {format: [:json]} end + post '/acknowledge_notification', to: 'users#acknowledge_notification' end #organisation admin area @@ -271,6 +272,10 @@ resources :themes, only: [] do get 'index/:page', action: :index, on: :collection, as: :index end + # Paginable actions for notifications + resources :notifications, only: [] do + get 'index/:page', action: :index, on: :collection, as: :index + end # Paginable actions for templates resources :templates, only: [] do get 'all/:page', action: :all, on: :collection, as: :all @@ -315,5 +320,6 @@ resources :orgs, only: [:index, :new, :create, :edit, :update, :destroy] resources :themes, only: [:index, :new, :create, :edit, :update, :destroy] resources :users, only: [:edit, :update] + resources :notifications end end diff --git a/db/migrate/20180123161959_change_long_strings_to_text.rb b/db/migrate/20180123161959_change_long_strings_to_text.rb index 47cef6d..c2ef100 100644 --- a/db/migrate/20180123161959_change_long_strings_to_text.rb +++ b/db/migrate/20180123161959_change_long_strings_to_text.rb @@ -1,9 +1,8 @@ class ChangeLongStringsToText < ActiveRecord::Migration def change - change_column :orgs, :links, :text - change_column :templates, :links, :text - change_column :identifier_schemes, :logo_url, :text - change_column :identifier_schemes, :user_landing_url, :text + # change_column :orgs, :links, :text + # change_column :templates, :links, :text + # change_column :identifier_schemes, :logo_url, :text + # change_column :identifier_schemes, :user_landing_url, :text end end - diff --git a/db/migrate/20180328115455_create_notifications.rb b/db/migrate/20180328115455_create_notifications.rb new file mode 100644 index 0000000..c6be87e --- /dev/null +++ b/db/migrate/20180328115455_create_notifications.rb @@ -0,0 +1,15 @@ +class CreateNotifications < ActiveRecord::Migration + def change + create_table :notifications do |t| + t.integer :notification_type + t.string :title + t.integer :level + t.text :body + t.boolean :dismissable + t.date :starts_at + t.date :expires_at + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20180412092647_create_notification_acknowledgements.rb b/db/migrate/20180412092647_create_notification_acknowledgements.rb new file mode 100644 index 0000000..bbb06cf --- /dev/null +++ b/db/migrate/20180412092647_create_notification_acknowledgements.rb @@ -0,0 +1,10 @@ +class CreateNotificationAcknowledgements < ActiveRecord::Migration + def change + create_table :notification_acknowledgements do |t| + t.belongs_to :user, foreign_key: true, index: true + t.belongs_to :notification, foreign_key: true, index: true + + t.timestamps null: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index ca41822..475283f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,10 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180315161757) do - - # These are extensions that must be enabled in order to support this database - enable_extension "plpgsql" +ActiveRecord::Schema.define(version: 20180412092647) do create_table "annotations", force: :cascade do |t| t.integer "question_id" @@ -80,7 +77,7 @@ create_table "friendly_id_slugs", force: :cascade do |t| t.string "slug", null: false t.integer "sluggable_id", null: false - t.string "sluggable_type", limit: 40 + t.string "sluggable_type" t.datetime "created_at" end @@ -139,6 +136,28 @@ add_index "notes", ["answer_id"], name: "index_notes_on_answer_id" + create_table "notification_acknowledgements", force: :cascade do |t| + t.integer "user_id" + t.integer "notification_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "notification_acknowledgements", ["notification_id"], name: "index_notification_acknowledgements_on_notification_id" + add_index "notification_acknowledgements", ["user_id"], name: "index_notification_acknowledgements_on_user_id" + + create_table "notifications", force: :cascade do |t| + t.integer "notification_type" + t.string "title" + t.integer "level" + t.text "body" + t.boolean "dismissable" + t.date "starts_at" + t.date "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "org_identifiers", force: :cascade do |t| t.string "identifier" t.string "attrs" @@ -384,11 +403,11 @@ create_table "users", force: :cascade do |t| t.string "firstname" t.string "surname" - t.string "email", default: "", null: false + t.string "email", default: "", null: false t.string "orcid_id" t.string "shibboleth_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "encrypted_password", default: "" t.string "reset_password_token" t.datetime "reset_password_sent_at" @@ -426,42 +445,4 @@ add_index "users_perms", ["user_id"], name: "index_users_perms_on_user_id" - add_foreign_key "annotations", "orgs" - add_foreign_key "annotations", "questions" - add_foreign_key "answers", "plans" - add_foreign_key "answers", "questions" - add_foreign_key "answers", "users" - add_foreign_key "answers_question_options", "answers" - add_foreign_key "answers_question_options", "question_options" - add_foreign_key "guidance_groups", "orgs" - add_foreign_key "guidances", "guidance_groups" - add_foreign_key "notes", "answers" - add_foreign_key "notes", "users" - add_foreign_key "org_identifiers", "identifier_schemes" - add_foreign_key "org_identifiers", "orgs" - add_foreign_key "org_token_permissions", "orgs" - add_foreign_key "org_token_permissions", "token_permission_types" - add_foreign_key "orgs", "languages" - add_foreign_key "orgs", "regions" - add_foreign_key "phases", "templates" - add_foreign_key "plans", "templates" - add_foreign_key "plans_guidance_groups", "guidance_groups" - add_foreign_key "plans_guidance_groups", "plans" - add_foreign_key "question_options", "questions" - add_foreign_key "questions", "question_formats" - add_foreign_key "questions", "sections" - add_foreign_key "questions_themes", "questions" - add_foreign_key "questions_themes", "themes" - add_foreign_key "roles", "plans" - add_foreign_key "roles", "users" - add_foreign_key "sections", "phases" - add_foreign_key "templates", "orgs" - add_foreign_key "themes_in_guidance", "guidances" - add_foreign_key "themes_in_guidance", "themes" - add_foreign_key "user_identifiers", "identifier_schemes" - add_foreign_key "user_identifiers", "users" - add_foreign_key "users", "languages" - add_foreign_key "users", "orgs" - add_foreign_key "users_perms", "perms" - add_foreign_key "users_perms", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 6257841..f41156e 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -5,8 +5,8 @@ # Identifier Schemes # ------------------------------------------------------- identifier_schemes = [ - {name: 'orcid', description: 'ORCID', active: true, - logo_url:'http://orcid.org/sites/default/files/images/orcid_16x16.png', + {name: 'orcid', description: 'ORCID', active: true, + logo_url:'http://orcid.org/sites/default/files/images/orcid_16x16.png', user_landing_url:'https://orcid.org' }, {name: 'shibboleth', description: 'Your institutional credentials', active: true, }, @@ -57,7 +57,7 @@ Dir.entries("#{Rails.root.join("config", "locales").to_s}").each do |f| if f[-4..-1] == '.yml' lang = f.gsub('.yml', '') - + if Language.where(abbreviation: lang).empty? Language.create!({ abbreviation: lang, @@ -72,10 +72,10 @@ # Regions (create the super regions first and then create the rest) # ------------------------------------------------------- regions = [ - {abbreviation: 'horizon', + {abbreviation: 'horizon', description: 'European super region', name: 'Horizon2020', - + sub_regions: [ {abbreviation: 'uk', description: 'United Kingdom', @@ -90,7 +90,7 @@ description: 'Spain', name: 'ES'} ]}, - {abbreviation: 'us', + {abbreviation: 'us', description: 'United States of America', name: 'US'} ] @@ -99,9 +99,9 @@ regions.each do |r| srs = r[:sub_regions] r.delete(:sub_regions) unless r[:sub_regions].nil? - + if Region.find_by(abbreviation: r[:abbreviation]).nil? - region = Region.create!(r) + region = Region.create!(r) unless srs.nil? srs.each do |sr| @@ -111,7 +111,7 @@ end end end - + end end @@ -179,7 +179,7 @@ ] orgs.map{ |o| Org.create!(o) if Org.find_by(abbreviation: o[:abbreviation]).nil? } -# Create a Super Admin associated with our generic organisation, +# Create a Super Admin associated with our generic organisation, # an Org Admin for our funder and an Org Admin and User for our University # ------------------------------------------------------- users = [ @@ -229,7 +229,7 @@ users.map{ |u| User.create!(u) if User.find_by(email: u[:email]).nil? } # Create a Guidance Group for our organisation and the funder -# ------------------------------------------------------- +# ------------------------------------------------------- guidance_groups = [ {name: "Generic Guidance (provided by the example curation centre)", org: Org.find_by(abbreviation: Rails.configuration.branding[:organisation][:abbreviation]), @@ -243,17 +243,17 @@ guidance_groups.map{ |gg| GuidanceGroup.create!(gg) if GuidanceGroup.find_by(name: gg[:name]).nil? } # Initialize with the generic Roadmap guidance and a sample funder guidance -# ------------------------------------------------------- +# ------------------------------------------------------- guidances = [ {text: "● Give a summary of the data you will collect or create, noting the content, coverage and data type, e.g., tabular data, survey data, experimental measurements, models, software, audiovisual data, physical samples, etc. -● Consider how your data could complement and integrate with existing data, or whether there are any existing data or methods that you could reuse. +● Consider how your data could complement and integrate with existing data, or whether there are any existing data or methods that you could reuse. ● If purchasing or reusing existing data, explain how issues such as copyright and IPR have been addressed. You should aim to m inimise any restrictions on the reuse (and subsequent sharing) of third-party data.", guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Data Description')]}, - {text: "● Clearly note what format(s) your data will be in, e.g., plain text (.txt), comma-separated values (.csv), geo-referenced TIFF (.tif, .tfw). -● Explain why you have chosen certain formats. Decisions may be based on staff expertise, a preference for open formats, the standards accepted by data centres or widespread usage within a given community. + {text: "● Clearly note what format(s) your data will be in, e.g., plain text (.txt), comma-separated values (.csv), geo-referenced TIFF (.tif, .tfw). +● Explain why you have chosen certain formats. Decisions may be based on staff expertise, a preference for open formats, the standards accepted by data centres or widespread usage within a given community. ● Using standardised, interchangeable or open formats ensures the long-term usability of data; these are recommended for sharing and archiving. ● See UK Data Service guidance on recommended formats or DataONE Best Practices for file formats", guidance_group: GuidanceGroup.first, @@ -265,9 +265,9 @@ guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Data Volume')]}, - {text: "● Outline how the data will be collected and processed. This should cover relevant standards or methods, quality assurance and data organisation. + {text: "● Outline how the data will be collected and processed. This should cover relevant standards or methods, quality assurance and data organisation. ● Indicate how the data will be organised during the project, mentioning, e.g., naming conventions, version control and folder structures. Consistent, well-ordered research data will be easier to find, understand and reuse -● Explain how the consistency and quality of data collection will be controlled and documented. This may include processes such as calibration, repeat samples or measurements, standardised data capture, data entry validation, peer review of data or representation with controlled vocabularies. +● Explain how the consistency and quality of data collection will be controlled and documented. This may include processes such as calibration, repeat samples or measurements, standardised data capture, data entry validation, peer review of data or representation with controlled vocabularies. ● See the DataOne Best Practices for data quality", guidance_group: GuidanceGroup.first, published: true, @@ -287,23 +287,23 @@ guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Ethics & Privacy')]}, - {text: "● State who will own the copyright and IPR of any new data that you will generate. For multi-partner projects, IPR ownership should be covered in the consortium agreement. -● Outline any restrictions needed on data sharing, e.g., to protect proprietary or patentable data. + {text: "● State who will own the copyright and IPR of any new data that you will generate. For multi-partner projects, IPR ownership should be covered in the consortium agreement. +● Outline any restrictions needed on data sharing, e.g., to protect proprietary or patentable data. ● Explain how the data will be licensed for reuse. See the DCC guide on How to license research data and EUDAT’s data and software licensing wizard.", guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Intellectual Property Rights')]}, {text: "● Describe where the data will be stored and backed up during the course of research activities. This may vary if you are doing fieldwork or working across multiple sites so explain each procedure. -● Identify who will be responsible for backup and how often this will be performed. The use of robust, managed storage with automatic backup, for example, that provided by university IT teams, is preferable. Storing data on laptops, computer hard drives or external storage devices alone is very risky. +● Identify who will be responsible for backup and how often this will be performed. The use of robust, managed storage with automatic backup, for example, that provided by university IT teams, is preferable. Storing data on laptops, computer hard drives or external storage devices alone is very risky. ● See UK Data Service Guidance on data storage or DataONE Best Practices for storage -● Also consider data security, particularly if your data is sensitive e.g., detailed personal data, politically sensitive information or trade secrets. Note the main risks and how these will be managed. +● Also consider data security, particularly if your data is sensitive e.g., detailed personal data, politically sensitive information or trade secrets. Note the main risks and how these will be managed. ● Identify any formal standards that you will comply with, e.g., ISO 27001. See the DCC Briefing Paper on Information Security Management -ISO 27000 and UK Data Service guidance on data security", guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Storage & Security')]}, - {text: "● How will you share the data e.g. deposit in a data repository, use a secure data service, handle data requests directly or use another mechanism? The methods used will depend on a number of factors such as the type, size, complexity and sensitivity of the data. -● When will you make the data available? Research funders expect timely release. They typically allow embargoes but not prolonged exclusive use. -● Who will be able to use your data? If you need to restricted access to certain communities or apply data sharing agreements, explain why. + {text: "● How will you share the data e.g. deposit in a data repository, use a secure data service, handle data requests directly or use another mechanism? The methods used will depend on a number of factors such as the type, size, complexity and sensitivity of the data. +● When will you make the data available? Research funders expect timely release. They typically allow embargoes but not prolonged exclusive use. +● Who will be able to use your data? If you need to restricted access to certain communities or apply data sharing agreements, explain why. ● Consider strategies to minimise restrictions on sharing. These may include anonymising or aggregating data, gaining participant consent for data sharing, gaining copyright permissions, and agreeing a limited embargo period. ● How might your data be reused in other contexts? Where there is potential for reuse, you should use standards and formats that facilitate this, and ensure that appropriate metadata is available online so your data can be discovered. Persistent identifiers should be applied so people can reliably and efficiently find your data. They also help you to track citations and reuse.", guidance_group: GuidanceGroup.first, @@ -321,19 +321,19 @@ guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Preservation')]}, - {text: "● Outline the roles and responsibilities for all activities, e.g., data capture, metadata production, data quality, storage and backup, data archiving & data sharing. Individuals should be named where possible. + {text: "● Outline the roles and responsibilities for all activities, e.g., data capture, metadata production, data quality, storage and backup, data archiving & data sharing. Individuals should be named where possible. ● For collaborative projects you should explain the coordination of data management responsibilities across partners. ● See UK Data Service guidance on data management roles and responsibilities or DataONE Best Practices: Define roles and assign responsibilities for data management", guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Roles & Responsibilities')]}, {text: "● Carefully consider and justify any resources needed to deliver the plan. These may include storage costs, hardware, staff time, costs of preparing data for deposit and repository charges. -● Outline any relevant technical expertise, support and training that is likely to be required and how it will be acquired. +● Outline any relevant technical expertise, support and training that is likely to be required and how it will be acquired. ● If you are not depositing in a data repository, ensure you have appropriate resources and systems in place to share and preserve the data. See UK Data Service guidance on costing data management", guidance_group: GuidanceGroup.first, published: true, themes: [Theme.find_by(title: 'Budget')]}, - {text: "● Consider whether there are any existing procedures that you can base your approach on. If your group/department has local guidelines that you work to, point to them here. + {text: "● Consider whether there are any existing procedures that you can base your approach on. If your group/department has local guidelines that you work to, point to them here. ● List any other relevant funder, institutional, departmental or group policies on data management, data sharing and data security. ", guidance_group: GuidanceGroup.first, published: true, @@ -346,7 +346,7 @@ guidances.map{ |g| Guidance.create!(g) if Guidance.find_by(text: g[:text]).nil? } # Create a default template for the curation centre and one for the example funder -# ------------------------------------------------------- +# ------------------------------------------------------- templates = [ {title: "My Curation Center's Default Template", description: "The default template", @@ -358,7 +358,7 @@ dmptemplate_id: 1, visibility: Template.visibilities[:publicly_visible], links: {"funder":[],"sample_plan":[]}}, - + {title: "OLD - Department of Testing Award", published: false, org: Org.find_by(abbreviation: 'GA'), @@ -368,7 +368,7 @@ visibility: Template.visibilities[:organisationally_visible], dmptemplate_id: 2, links: {"funder":[],"sample_plan":[]}}, - + {title: "Department of Testing Award", published: true, org: Org.find_by(abbreviation: 'GA'), @@ -379,11 +379,11 @@ dmptemplate_id: 3, links: {"funder":[],"sample_plan":[]}} ] -# Template creation calls defaults handler which sets is_default and +# Template creation calls defaults handler which sets is_default and # published to false automatically, so update them after creation -templates.map do |t| - if Template.find_by(title: t[:title]).nil? - tmplt = Template.create!(t) +templates.map do |t| + if Template.find_by(title: t[:title]).nil? + tmplt = Template.create!(t) tmplt.published = t[:published] tmplt.is_default = t[:is_default] tmplt.visibility = t[:visibility] @@ -392,18 +392,18 @@ end # Create 2 phases for the funder's template and one for our generic template -# ------------------------------------------------------- +# ------------------------------------------------------- phases = [ {title: "Generic Data Management Planning Template", number: 1, modifiable: false, template: Template.find_by(title: "My Curation Center's Default Template")}, - + {title: "Detailed Overview", number: 1, modifiable: false, template: Template.find_by(title: "OLD - Department of Testing Award")}, - + {title: "Preliminary Statement of Work", number: 1, modifiable: true, @@ -420,7 +420,7 @@ funder_template_phase_2 = Phase.find_by(title: "Detailed Overview") # Create sections for the 2 templates and their phases -# ------------------------------------------------------- +# ------------------------------------------------------- sections = [ # Sections for the Generic Template {title: "Data Collection", @@ -464,7 +464,7 @@ number: 1, published: false, modifiable: true, - phase: Phase.find_by(title: "Detailed Overview")}, + phase: Phase.find_by(title: "Detailed Overview")}, # Sections for the Funder Template's Preliminary Phase {title: "Data Overview", @@ -510,7 +510,7 @@ text_area = QuestionFormat.find_by(title: "Text area") # Create questions for the 2 templates and their phases -# ------------------------------------------------------- +# ------------------------------------------------------- questions = [ # Questions for the Generic Template {text: "What data will you collect or create?", @@ -588,7 +588,7 @@ modifiable: false, section: Section.find_by(title: "Responsibilities and Resources"), question_format: text_area}, - + # Questions for old version of Funder Template {text: "What data will you collect and how will it be obtained?", number: 1, @@ -600,7 +600,7 @@ modifiable: false, section: Section.find_by(title: "Data Collection and Preservation"), question_format: text_area}, - + # Questions for the Funder Template's Preliminary Phase {text: "Provide an overview of the dataset.", number: 1, @@ -620,7 +620,7 @@ section: Section.find_by(title: "Data Description"), question_format: text_area, themes: [Theme.find_by(title: "Data Collection")]}, - + # Questions for the Funder Template's Detailed Phase {text: "What is your policy for long term access to your dataset?", number: 1, @@ -689,11 +689,11 @@ questions.map{ |q| Question.create!(q) if Question.find_by(text: q[:text]).nil? } drop_down_question = Question.find_by(text: "Where will you store your data during the research period?") -multi_select_question = Question.find_by(text: "What type(s) of data will you collect?") +multi_select_question = Question.find_by(text: "What type(s) of data will you collect?") radio_button_question = Question.find_by(text: "Please select the appropriate formats.") # Create suggested answers for a few questions -# ------------------------------------------------------- +# ------------------------------------------------------- annotations = [ {text: "We will preserve it in Dryad or a similar data repository service.", type: Annotation.types[:example_answer], @@ -707,7 +707,7 @@ annotations.map{ |s| Annotation.create!(s) if Annotation.find_by(text: s[:text]).nil? } # Create options for the dropdown, multi-select and radio buttons -# ------------------------------------------------------- +# ------------------------------------------------------- question_options = [ {text: "csv files", number: 1, @@ -721,7 +721,7 @@ number: 3, question: radio_button_question, is_default: false}, - + {text: "local hard drive", number: 1, question: drop_down_question, @@ -734,7 +734,7 @@ number: 3, question: drop_down_question, is_default: false}, - + {text: "statistical", number: 1, question: multi_select_question, @@ -755,7 +755,7 @@ question_options.map{ |q| QuestionOption.create!(q) if QuestionOption.find_by(text: q[:text]).nil? } # Create plans -# ------------------------------------------------------- +# ------------------------------------------------------- =begin plans = [ {title: "Sample plan", @@ -787,7 +787,7 @@ plan: plan, user: user, question: Question.find_by(text: "How will you store the data and how will it be preserved?")}, - + {text: "We want people to be able to access it. ", plan: plan, user: user, @@ -799,7 +799,7 @@ {plan: plan, user: user, question: multi_select_question, - question_options: [QuestionOption.find_by(text: "image/video"), + question_options: [QuestionOption.find_by(text: "image/video"), QuestionOption.find_by(text: "other")]}, {plan: plan, user: user, diff --git a/lib/assets/javascripts/application.js b/lib/assets/javascripts/application.js index 17f32e2..0262849 100644 --- a/lib/assets/javascripts/application.js +++ b/lib/assets/javascripts/application.js @@ -49,3 +49,4 @@ import './views/usage/index'; import './views/users/notification_preferences'; import './views/users/admin_grant_permissions'; +import './views/super_admin/notifications/edit'; diff --git a/lib/assets/javascripts/views/super_admin/notifications/edit.js b/lib/assets/javascripts/views/super_admin/notifications/edit.js new file mode 100644 index 0000000..7ec8378 --- /dev/null +++ b/lib/assets/javascripts/views/super_admin/notifications/edit.js @@ -0,0 +1,11 @@ +import ariatiseForm from '../../../utils/ariatiseForm'; +import { Tinymce } from '../../../utils/tinymce'; + +$(() => { + Tinymce.init({ + selector: '#notification_body', + forced_root_block: '', + toolbar: 'bold italic underline | link', + }); + ariatiseForm({ selector: 'form.notification' }); +}); diff --git a/lib/tasks/notifications.rake b/lib/tasks/notifications.rake new file mode 100644 index 0000000..c536e06 --- /dev/null +++ b/lib/tasks/notifications.rake @@ -0,0 +1,7 @@ +namespace :notifications do + desc "Create some notifications types" + task create_types: :environment do + NotificationType.create(name: 'global') + end + +end diff --git a/test/functional/notifications_controller_test.rb b/test/functional/notifications_controller_test.rb new file mode 100644 index 0000000..8616c5d --- /dev/null +++ b/test/functional/notifications_controller_test.rb @@ -0,0 +1,83 @@ +require 'test_helper' +module SuperAdmin + class NotificationsControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + + setup do + @super_admin = User.find_by(email: 'super_admin@example.com') + scaffold_org_admin(Org.last) + + @notification_attributes = { + notification_type: Notification.notification_types[:global], + title: 'notification_1', + level: Notification.levels[:info], + body: 'notification 1', + dismissable: true, + starts_at: Date.today, + expires_at: Date.tomorrow + } + @notification = Notification.create!(@notification_attributes) + end + + test 'should get index' do + sign_in @super_admin + get :index + assert_response :success + assert_not_nil assigns(:notifications) + end + + test 'should get new' do + sign_in @super_admin + get :new + assert_response :success + end + + test 'should create notification' do + sign_in @super_admin + assert_difference('Notification.count') do + @notification_attributes[:level] = :info #controller is expecting the symbol instead of the numerical value + post :create, notification: @notification_attributes + end + assert_redirected_to super_admin_notifications_url + end + + test 'should get edit' do + sign_in @super_admin + get :edit, id: @notification + assert_response :success + assert_not_nil assigns(:notification) + end + + test 'should update notification' do + sign_in @super_admin + @notification_attributes[:title] = 'notification_2' + @notification_attributes[:level] = :info #controller is expecting the symbol instead of the numerical value + patch :update, id: @notification, notification: @notification_attributes + assert_redirected_to super_admin_notifications_url + end + + test 'should destroy notification' do + sign_in @super_admin + assert_difference('Notification.count', -1) do + delete :destroy, id: @notification + end + assert_redirected_to super_admin_notifications_url + end + + test 'unauthorized redirections' do + sign_in @user + get :index + assert_redirected_to(plans_url) + get :new + assert_redirected_to(plans_url) + post :create, notification: @notification_attributes + assert_redirected_to(plans_url) + get :edit, id: @notification + assert_redirected_to(plans_url) + patch :update, id: @notification, notification: @notification_attributes + assert_redirected_to(plans_url) + delete :destroy, id: @notification + assert_redirected_to(plans_url) + end + end +end diff --git a/test/unit/notification_test.rb b/test/unit/notification_test.rb new file mode 100644 index 0000000..7be2e1b --- /dev/null +++ b/test/unit/notification_test.rb @@ -0,0 +1,52 @@ +require 'test_helper' + +class NotificationTest < ActiveSupport::TestCase + + setup do + @super_admin = User.find_by(email: 'super_admin@example.com') + + @notification = Notification.create!( + notification_type: Notification.notification_types[:global], + title: 'notification_1', + level: Notification.levels[:info], + body: 'notification 1', + dismissable: true, + starts_at: Time.now, + expires_at: Time.now + 1.days) + end + + # Validity + test 'validations valid' do + 1.upto(10) { |i| assert(@notification.valid?) } + end + + # Date validation + test 'validations inconsistent dates' do + @notification.expires_at = Date.today - 1.days + assert_not(@notification.valid?) + end + + # Missing parameters + test 'validations missing params' do + @notification.dismissable = nil + assert(@notification.valid?) + @notification.dismissable = false + @notification.notification_type = nil + assert_not(@notification.valid?) + @notification.notification_type = Notification.notification_types[:global] + @notification.title = nil + assert_not(@notification.valid?) + @notification.title = "Testing" + @notification.body = nil + assert_not(@notification.valid?) + @notification.body = "Testing" + @notification.level = nil + assert_not(@notification.valid?) + @notification.level = Notification.levels[:info] + @notification.starts_at = nil + assert_not(@notification.valid?) + @notification.starts_at = Time.now + @notification.expires_at = nil + assert_not(@notification.valid?) + end +end diff --git a/test/unit/perm_test.rb b/test/unit/perm_test.rb index a40c704..a651315 100644 --- a/test/unit/perm_test.rb +++ b/test/unit/perm_test.rb @@ -1,7 +1,6 @@ require 'test_helper' class PermTest < ActiveSupport::TestCase - setup do @user = User.last diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb index 0fa96b6..9a1dbce 100644 --- a/test/unit/user_test.rb +++ b/test/unit/user_test.rb @@ -15,6 +15,15 @@ org: Org.last, api_token: 'ABC123', language: Language.find_by(abbreviation: I18n.locale)) + + @notification = Notification.create!( + notification_type: Notification.notification_types[:global], + title: 'notification_1', + level: Notification.levels[:info], + body: 'notification 1', + dismissable: false, + starts_at: Date.today, + expires_at: Date.tomorrow) end # --------------------------------------------------- @@ -349,4 +358,16 @@ assert_equal(previous_api_token, @user.api_token) assert_equal(previous_perms, @user.perms) end + + # Cannot dismiss Notifications that are non-dismissable + test 'cannot acknowledge a notification that is not dismissable' do + @user.acknowledge(@notification) + assert_not(@notification.acknowledged?(@user)) + end + # Can dismiss Notifications that are dismissable + test 'can acknowledge a notification' do + @notification.update!(dismissable: true) + @user.acknowledge(@notification) + assert(@notification.acknowledged?(@user)) + end end