diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index f2ad52044..f66ad7708 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -61,6 +61,7 @@ @import "pages/sources"; @import "pages/workflows"; @import "groups"; +@import "pages/curator"; // Vendor @import "cytoscape-panzoom"; diff --git a/app/assets/stylesheets/pages/curator.scss b/app/assets/stylesheets/pages/curator.scss new file mode 100644 index 000000000..0570c7bed --- /dev/null +++ b/app/assets/stylesheets/pages/curator.scss @@ -0,0 +1,14 @@ +.curator-floating-actions { + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + z-index: 1050; + background-color: rgba(255, 255, 255, 0.512); + padding: 10px 20px; + border-radius: 25px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + border: 1px solid #ddd; + display: flex; + gap: 10px; +} \ No newline at end of file diff --git a/app/controllers/curator_controller.rb b/app/controllers/curator_controller.rb index e325040c2..52d56a633 100644 --- a/app/controllers/curator_controller.rb +++ b/app/controllers/curator_controller.rb @@ -1,11 +1,19 @@ # The controller for actions related to the curator model class CuratorController < ApplicationController - CURATION_ACTIONS = %w(material.add_term event.add_term material.reject_term event.reject_term) + CURATION_ACTIONS = %w(material.add_term event.add_term material.reject_term event.reject_term material.approval_status_changed event.approval_status_changed).freeze + CURATABLE_TYPES = { + 'materials' => Material, + 'events' => Event + }.freeze + + APPROVAL_FEATURE_FLAGS = { + 'materials' => 'material_under_admin_approval', + 'events' => 'event_under_admin_approval' + }.freeze before_action :check_curator before_action :set_breadcrumbs, :only => [:topic_suggestions] - # Hacky stub to make breadcrumbs work def index redirect_to '/curate/topic_suggestions' end @@ -57,15 +65,106 @@ def users end end + def resources + @status = params[:status].presence || 'requested' + @type = params[:type].presence || 'all' + + target_classes = @type == 'all' ? enabled_curatable_classes : [enabled_curatable_types[@type]].compact + + page = (params[:page] || 1).to_i + per_page = (params[:per_page] || 20).to_i + fetch_limit = page * per_page + + # Total count across enabled classes + total_count = target_classes.sum { |klass| fetch_curatable_records(klass).count } + + # Bounded fetch to prevent memory spikes on massive queues + records = target_classes.flat_map do |klass| + fetch_curatable_records(klass).order(updated_at: :desc).limit(fetch_limit).to_a + end + + sorted_records = records.sort_by(&:updated_at).reverse + + @resources = WillPaginate::Collection.create(page, per_page, total_count) do |pager| + pager.replace(sorted_records[pager.offset, pager.per_page] || []) + end + + respond_to(&:html) + end + + def bulk_approve + status = params[:status].presence || 'requested' + type = params[:type].presence || 'all' + action = params[:approve_action] + new_status = action == 'reject' ? 'not_approved' : 'approved' + target_classes = type == 'all' ? enabled_curatable_classes : [enabled_curatable_types[type]].compact + + updated_count = 0 + target_classes.each do |klass| + fetch_curatable_records(klass).find_each do |resource| + updated_count += 1 if resource.update(approval_status: new_status) + end + end + + redirect_to params[:redirect_to].presence || curate_resources_path, + notice: "#{updated_count} resource(s) successfully #{new_status.humanize.downcase}." + end + private + def enabled_curatable_types + CURATABLE_TYPES.select { |type, _| TeSS::Config.feature[APPROVAL_FEATURE_FLAGS[type]] } + end + + def enabled_curatable_classes + enabled_curatable_types.values + end + def action_count_for(action) return PublicActivity::Activity.where(key: action).group_by{|logs| logs.owner}.sort_by{|user, logs| -logs.count}.map{|user,logs| [user, logs.count]}.to_h end + def fetch_curatable_records(klass) + scope = ApplicationPolicy::Scope.new(pundit_user, klass).resolve_curatable + + # Default to 'requested' if params[:status] is blank + status = params[:status].presence || 'requested' + if status != 'all' + status_code = klass::APPROVAL_STATUS_CODES[status.to_sym] || status + scope = scope.where(approval_status: status_code) + end + + scope = scope.where(content_provider_id: params[:content_provider_id]) if params[:content_provider_id].present? + scope = scope.where(user_id: params[:user_id]) if params[:user_id].present? && klass.reflect_on_association(:user) + + if params[:space_id].present? && klass.reflect_on_association(:space) + if params[:space_id] == 'default' && Space.respond_to?(:default) + scope = scope.where(space_id: [Space.default&.id, nil]) + else + scope = scope.where(space_id: params[:space_id]) + end + end + + scope.includes(:user, :content_provider, (:space if klass.reflect_on_association(:space))) + end + def check_curator - unless current_user && (current_user.is_admin? || current_user.is_curator?) + unless current_user && (current_user.is_admin? || current_user.is_curator? || current_user.has_role_in_any_space?('admin')) handle_error(:forbidden, 'This page is only visible to curators.') end end -end + + def recent_resource_approvals + activity_ids = enabled_curatable_classes.flat_map do |klass| + allowed_ids = ApplicationPolicy::Scope.new(pundit_user, klass).resolve_curatable.pluck(:id) + key = "#{klass.name.underscore}.approval_status_changed" + PublicActivity::Activity.where(trackable_type: klass.name, trackable_id: allowed_ids, key: key).pluck(:id) + end + + PublicActivity::Activity.where(id: activity_ids) + .includes(:owner, trackable: [:content_provider, :space]) + .order(created_at: :desc) + .limit(10) + end + helper_method :recent_resource_approvals +end \ No newline at end of file diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index bdfe153fb..efb59965b 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -3,7 +3,7 @@ # The controller for actions related to the Events model class EventsController < ApplicationController before_action :ensure_feature_enabled - before_action :set_event, only: %i[show edit clone update destroy update_collections add_term reject_term + before_action :set_event, only: %i[show edit clone update destroy update_collections request_approval add_term reject_term redirect report update_report add_data reject_data] before_action :set_breadcrumbs before_action :disable_pagination, only: :index, if: ->(controller) { controller.request.format.ics? or controller.request.format.csv? or controller.request.format.rss? } @@ -16,7 +16,7 @@ class EventsController < ApplicationController # GET /events # GET /events.json def index - @bioschemas = @events.flat_map(&:to_bioschemas) + @bioschemas = @events.select { |e| policy(e).show? }.flat_map(&:to_bioschemas) # `policy(m).show?` allows to display JSON-LD metadata of events that are visible in the UI to the user, whether unauth, basic, curator or admin @past_events_count = 0 if TeSS::Config.solr_enabled && @events.none? && @facet_params[:include_expired] != 'true' && request.format.html? @past_events_count = Event.search_and_filter(current_user, @@ -158,6 +158,11 @@ def create @event.user = current_user @event.space = current_space + # apply approval_status from params only if the built record's policy allows it + if params[:event].present? && params[:event].key?(:approval_status) && policy(@event).approve? + @event.approval_status = params[:event][:approval_status] + end + respond_to do |format| if @event.save @event.create_activity :create, owner: current_user @@ -175,9 +180,10 @@ def create def update authorize @event respond_to do |format| - if @event.update(event_params) + if @event.update(event_params(@event)) @event.create_activity(:update, owner: current_user) if @event.log_update_activity? - format.html { redirect_to @event, notice: 'Event was successfully updated.' } + redirect_path = params[:redirect_to].presence || @event + format.html { redirect_to redirect_path, notice: 'Event was successfully updated.' } format.json { render :show, status: :ok, location: @event } else format.html { render :edit } @@ -217,6 +223,24 @@ def update_collections redirect_to @event end + # POST /events/1/request_approval + def request_approval + authorize @event + + if @event.approval_requested? + flash[:error] = 'Approval request has already been submitted.' + elsif @event.approved? + flash[:error] = 'Already approved.' + elsif @event.not_approved? + @event.request_approval + flash[:notice] = 'Approval request was sent successfully.' + end + + respond_to do |format| + format.html { redirect_to @event } + end + end + def redirect @event.widget_logs.create(widget_name: params[:widget], action: "#{controller_name}##{action_name}", @@ -234,21 +258,30 @@ def set_event end # Never trust parameters from the scary internet, only allow the white list through. - def event_params - params.require(:event).permit(:external_id, :title, :subtitle, :url, :organizer, :last_scraped, :scraper_record, - :description, { scientific_topic_names: [] }, { scientific_topic_uris: [] }, - { operation_names: [] }, { operation_uris: [] }, { event_types: [] }, - { keywords: [] }, { fields: [] }, :start, :end, :duration, { sponsors: [] }, - :online, :venue, :city, :county, :country, :postcode, :latitude, :longitude, - :timezone, :content_provider_id, { collection_ids: [] }, { node_ids: [] }, - { node_names: [] }, { target_audience: [] }, { eligibility: [] }, :visible, - { host_institutions: [] }, :capacity, :contact, :recognition, :learning_objectives, - :prerequisites, :tech_requirements, :cost_basis, :cost_value, :cost_currency, :language, - :presence, :origin_uri, - external_resources_attributes: %i[id url title _destroy], - external_resources: %i[url title], material_ids: [], - llm_interaction_attributes: %i[id scrape_or_process model prompt input output needs_processing _destroy], - locked_fields: [], instructors: [:name, :orcid], contributors: [:name, :orcid]) + # When a record is provided, gate approval-specific fields using the record-level policy (policy(record).approve?) + # For create flows where the record is not yet built, approval fields are applied explicitly after building the record. + def event_params(record = nil) + permitted = [:external_id, :title, :subtitle, :url, :organizer, :last_scraped, :scraper_record, + :description, { scientific_topic_names: [] }, { scientific_topic_uris: [] }, + { operation_names: [] }, { operation_uris: [] }, { event_types: [] }, + { keywords: [] }, { fields: [] }, :start, :end, :duration, { sponsors: [] }, + :online, :venue, :city, :county, :country, :postcode, :latitude, :longitude, + :timezone, :content_provider_id, { collection_ids: [] }, { node_ids: [] }, + { node_names: [] }, { target_audience: [] }, { eligibility: [] }, :visible, + { host_institutions: [] }, :capacity, :contact, :recognition, :learning_objectives, + :prerequisites, :tech_requirements, :cost_basis, :cost_value, :cost_currency, :language, + :presence, :origin_uri, + external_resources_attributes: %i[id url title _destroy], + external_resources: %i[url title], material_ids: [], + llm_interaction_attributes: %i[id scrape_or_process model prompt input output needs_processing _destroy], + locked_fields: [], instructors: [:name, :orcid], contributors: [:name, :orcid]] + + # Only permit approval_status when a record is present and the record-level policy allows approval + if record && policy(record).approve? + permitted << :approval_status + end + + params.require(:event).permit(permitted) end def event_report_params diff --git a/app/controllers/materials_controller.rb b/app/controllers/materials_controller.rb index 9a53fa0d3..c4a5ef582 100644 --- a/app/controllers/materials_controller.rb +++ b/app/controllers/materials_controller.rb @@ -1,7 +1,7 @@ # The controller for actions related to the Materials model class MaterialsController < ApplicationController before_action :ensure_feature_enabled - before_action :set_material, only: %i[show edit update destroy update_collections clone + before_action :set_material, only: %i[show edit update destroy update_collections request_approval clone add_term reject_term add_data reject_data] before_action :set_breadcrumbs before_action :set_learning_path_navigation, only: :show @@ -18,7 +18,7 @@ class MaterialsController < ApplicationController def index elearning = @facet_params[:resource_type] == 'e-learning' && feature_enabled?('elearning_materials') - @bioschemas = @materials.flat_map(&:to_bioschemas) + @bioschemas = @materials.select { |m| policy(m).show? }.flat_map(&:to_bioschemas) # `policy(m).show?` allows to display JSON-LD metadata of materials that are visible in the UI to the user, whether unauth, basic, curator or admin respond_to do |format| format.html { render elearning ? 'elearning_materials/index' : 'index' } format.json @@ -97,6 +97,11 @@ def create @material.user = current_user @material.space = current_space + # apply approval_status from params only if the built record's policy allows it + if params[:material].present? && params[:material].key?(:approval_status) && policy(@material).approve? + @material.approval_status = params[:material][:approval_status] + end + respond_to do |format| if @material.save @material.create_activity :create, owner: current_user @@ -114,9 +119,10 @@ def create def update authorize @material respond_to do |format| - if @material.update(material_params) + if @material.update(material_params(@material)) @material.create_activity(:update, owner: current_user) if @material.log_update_activity? - format.html { redirect_to @material, notice: 'Material was successfully updated.' } + redirect_path = params[:redirect_to].presence || @material + format.html { redirect_to redirect_path, notice: 'Material was successfully updated.' } format.json { render :show, status: :ok, location: @material } else format.html { render :edit } @@ -156,6 +162,24 @@ def update_collections redirect_to @material end + # POST /materials/1/request_approval + def request_approval + authorize @material + + if @material.approval_requested? + flash[:error] = 'Approval request has already been submitted.' + elsif @material.approved? + flash[:error] = 'Already approved.' + elsif @material.not_approved? + @material.request_approval + flash[:notice] = 'Approval request was sent successfully.' + end + + respond_to do |format| + format.html { redirect_to @material } + end + end + private # Use callbacks to share common setup or constraints between actions. @@ -164,23 +188,30 @@ def set_material end # Never trust parameters from the scary internet, only allow the white list through. - def material_params - params.require(:material).permit(:id, :title, :url, :contact, :description, :short_description, - :long_description, :doi, :licence, - :last_scraped, :scraper_record, :remote_created_date, :remote_updated_date, - :content_provider_id, :difficulty_level, :version, :status, - :date_created, :date_modified, :date_published, :other_types, - :prerequisites, :syllabus, :visible, :learning_objectives, :origin_uri, { subsets: [] }, - { target_audience: [] }, - { collection_ids: [] }, { keywords: [] }, { resource_type: [] }, - { scientific_topic_names: [] }, { scientific_topic_uris: [] }, - { operation_names: [] }, { operation_uris: [] }, - { node_ids: [] }, { node_names: [] }, { fields: [] }, - { authors: [:name, :orcid] }, { contributors: [:name, :orcid] }, # Structured - { authors: [] }, { contributors: [] }, # as strings - external_resources_attributes: %i[id url title _destroy], - external_resources: %i[url title], - event_ids: [], locked_fields: []) + def material_params(record = nil) + permitted = [:id, :title, :url, :contact, :description, :short_description, + :long_description, :doi, :licence, + :last_scraped, :scraper_record, :remote_created_date, :remote_updated_date, + :content_provider_id, :difficulty_level, :version, :status, + :date_created, :date_modified, :date_published, :other_types, + :prerequisites, :syllabus, :visible, :learning_objectives, :origin_uri, { subsets: [] }, + { target_audience: [] }, + { collection_ids: [] }, { keywords: [] }, { resource_type: [] }, + { scientific_topic_names: [] }, { scientific_topic_uris: [] }, + { operation_names: [] }, { operation_uris: [] }, + { node_ids: [] }, { node_names: [] }, { fields: [] }, + { authors: [:name, :orcid] }, { contributors: [:name, :orcid] }, # Structured + { authors: [] }, { contributors: [] }, # as strings + external_resources_attributes: %i[id url title _destroy], + external_resources: %i[url title], + event_ids: [], locked_fields: []] + + # Only permit approval_status when a record is present and the record-level policy allows approval + if record && policy(record).approve? + permitted << :approval_status + end + + params.require(:material).permit(permitted) end def set_learning_path_navigation diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f390e6f15..19615cc64 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -3,6 +3,7 @@ # The core application helper module ApplicationHelper IGNORED_FILTERS = %w[user].freeze + CURATION_FILTERS = %w[approval_status].freeze # add here the attributes shown as filters to be hidden from users but not from admin and curators # def bootstrap_class_for flash_type # { success: "alert-success", error: "alert-danger", alert: "alert-warning", notice: "alert-info" }[flash_type] || flash_type.to_s @@ -31,6 +32,8 @@ module ApplicationHelper check: { icon: 'fa-check', message: 'This resource is enabled' }, cross: { icon: 'fa-times', message: 'This resource has been disabled' }, exchanged: { icon: 'fa-exchange', message: 'This resource originated from another TeSS registry' }, + approval_requested: { icon: 'fa-eye', message: 'This resource has been requested an approval' }, + not_approved: { icon: 'fa-ban', message: 'This resource has been rejected by an admin' } }.freeze # Countries that have priority in the country selection menu. Using ISO 3166-1 Alpha2 code. @@ -86,6 +89,18 @@ def suggestion_icon(record, size = nil) "#{icon_for(:suggestion, size)}".html_safe end + def approval_requested_icon(record, size = nil) + return unless record.approval_requested? + + "#{icon_for(:approval_requested, size)}".html_safe + end + + def not_approved_icon(record, size = nil) + return unless record.not_approved? + + "#{icon_for(:not_approved, size)}".html_safe + end + def event_status_icon(event, size = nil) if event.started? "#{icon_for(:started, size)}".html_safe @@ -647,12 +662,11 @@ def cookie_consent end def available_facets(resources) - if (selected_facets = TeSS::Config.solr_facets&.fetch(controller_name, nil)) - indices = selected_facets.map { |name| resources.facets.index { |f| f.field_name.to_s == name } }.compact - resources.facets.values_at(*indices) - else - resources.facets - end.select { |f| f.rows.any? && !IGNORED_FILTERS.include?(f.field_name.to_s) } + filter_facets(resources) { |name| !IGNORED_FILTERS.include?(name) && !CURATION_FILTERS.include?(name) } + end + + def curation_facets(resources) + filter_facets(resources) { |name| CURATION_FILTERS.include?(name) } end def render_language_name(code) @@ -742,4 +756,22 @@ def flatten_params(prefix, value) [[prefix, value]] end end + + def filter_facets(resources) + raw_facets = resources.facets + selected_names = TeSS::Config.solr_facets&.fetch(controller_name, nil) + + facets = if selected_names + indices = selected_names.map { |name| raw_facets.index { |f| f.field_name.to_s == name } }.compact + raw_facets.values_at(*indices) + else + raw_facets + end + + facets.select do |f| + name = f.field_name.to_s + f.rows.any? && yield(name) + end + end + end diff --git a/app/helpers/curators_helper.rb b/app/helpers/curators_helper.rb index 3c912f57e..82d828ede 100644 --- a/app/helpers/curators_helper.rb +++ b/app/helpers/curators_helper.rb @@ -52,4 +52,28 @@ def approval_message(role_id) content_tag(:span, text, class: css_class) end + + def curator_space_options(user) + is_global = user.is_admin? || user.is_curator? + + if is_global + options = [] + options << ['Default Space', 'default'] if Space.respond_to?(:default) && Space.default + options + Space.where.not(id: Space.default&.id).order(:title).map { |s| [s.title, s.id] } + else + admin_space_ids = user.space_roles.where(key: 'admin').pluck(:space_id) + Space.where(id: admin_space_ids).order(:title).map { |s| [s.title, s.id] } + end + end + + def resource_approval_badge(status) + case status.to_sym + when :not_approved + content_tag(:span, t("resources.approval_status.#{status}"), class: 'label label-danger') + when :requested + content_tag(:span, t("resources.approval_status.#{status}"), class: 'label label-warning') + when :approved + content_tag(:span, t("resources.approval_status.#{status}"), class: 'label label-success') + end + end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 4a72a3a08..bbde9b6b1 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -163,4 +163,12 @@ def neatly_printed_date_range(start, finish = nil) end end end + + def approval_options_for_select_event + Event::APPROVAL_STATUS.values.map { |status| [t("events.approval_status.#{status}"), status] } + end + + def event_enabled_badge(enabled) + content_tag(:span, enabled ? 'Enabled' : 'Disabled', class: "label label-#{enabled ? 'success' : 'danger'}") + end end diff --git a/app/helpers/materials_helper.rb b/app/helpers/materials_helper.rb index c8a91f3cb..a02f57d6e 100644 --- a/app/helpers/materials_helper.rb +++ b/app/helpers/materials_helper.rb @@ -169,4 +169,23 @@ def keywords_and_topics(resource, limit: nil) tags << '…'.html_safe if limit_exceeded safe_join(tags, ' ') end + + def approval_options_for_select_material + Material::APPROVAL_STATUS.values.map { |status| [t("materials.approval_status.#{status}"), status] } + end + + def material_enabled_badge(enabled) + content_tag(:span, enabled ? 'Enabled' : 'Disabled', class: "label label-#{enabled ? 'success' : 'danger'}") + end + + def material_approval_badge(status) + case status.to_sym + when :not_approved + content_tag(:span, t("materials.approval_status.#{status}"), class: 'label label-danger') + when :requested + content_tag(:span, t("materials.approval_status.#{status}"), class: 'label label-warning') + when :approved + content_tag(:span, t("materials.approval_status.#{status}"), class: 'label label-success') + end + end end diff --git a/app/models/concerns/searchable.rb b/app/models/concerns/searchable.rb index 6073cb1bd..82e409f3c 100644 --- a/app/models/concerns/searchable.rb +++ b/app/models/concerns/searchable.rb @@ -19,6 +19,15 @@ def search_and_facet_keys @search_and_facet_keys ||= ([:q] | facet_keys_with_multiple) end + def user_can_curate_unapproved_in_space?(user, space) + return false unless user + return true if user.is_admin? + return true if space && !space.default? && user.has_space_role?(space, 'admin') + return true if user.is_curator? && (space.nil? || space.default? || !space.is_private) + + false + end + # Searches and filters resources of the including model using Solr (via Sunspot), # applying space-based visibility, facets, sorting and pagination in a single query. # @@ -59,6 +68,13 @@ def search_and_filter(user, search_params = '', selected_facets = {}, page: 1, s inaccessible_space_ids = Space.where(is_private: true).pluck(:id) end + filter_material_approval = name == 'Material' && + TeSS::Config.feature['material_under_admin_approval'] && + !user_can_curate_unapproved_in_space?(user, space) + filter_event_approval = name == 'Event' && + TeSS::Config.feature['event_under_admin_approval'] && + !user_can_curate_unapproved_in_space?(user, space) + search(include: includes) do if has_space if space && !space.default? @@ -86,20 +102,36 @@ def search_and_filter(user, search_params = '', selected_facets = {}, page: 1, s if sort_by && sort_by != 'default' case sort_by - when 'early' then order_by(:start, :asc) - when 'late' then order_by(:start, :desc) - when 'rel' then nil - when 'mod' then order_by(:updated_at, :desc) - when 'new' then order_by(:created_at, :desc) - when 'finished' then order_by(:finished_at, :desc) - else order_by(:sort_title, sort_by.to_sym) + when 'early' + # Sort by start date asc + order_by(:start, :asc) + when 'late' + # Sort by start date desc + order_by(:start, :desc) + when 'rel' + # Sort by relevance + when 'mod' + # Sort by last modified + order_by(:updated_at, :desc) + when 'new' + # Sort by newest + order_by(:created_at, :desc) + when 'finished' + # Sort by last finished + order_by(:finished_at, :desc) + else + order_by(:sort_title, sort_by.to_sym) end else case name - when 'Event' then order_by(:start, :asc) - when 'ContentProvider' then order_by(:count, :desc) - when 'Material' then order_by(:created_at, :desc) - else order_by(:sort_title, :asc) + when 'Event' + order_by(:start, :asc) + when 'ContentProvider' + order_by(:count, :desc) + when 'Material' + order_by(:approval_status, :asc) + else + order_by(:sort_title, :asc) end end @@ -114,7 +146,7 @@ def search_and_filter(user, search_params = '', selected_facets = {}, page: 1, s if name == 'Trainer' || name == 'Profile' any_of { with(:public, true) } - elsif has_public && !user&.is_admin? + elsif has_public && !(user&.is_admin? || user&.is_curator?) any_of do with(:public, true) with(:user_id, user.id) if user @@ -122,6 +154,20 @@ def search_and_filter(user, search_params = '', selected_facets = {}, page: 1, s end end + if filter_material_approval + any_of do + with(:approval_status, I18n.t('materials.approval_status.approved')) + with(:user_id, user.id) if user.present? + end + end + + if filter_event_approval + any_of do + with(:approval_status, I18n.t('events.approval_status.approved')) + with(:user_id, user.id) if user.present? + end + end + facet_fields.each do |ff| facet ff, exclude: active_facets[ff] end diff --git a/app/models/event.rb b/app/models/event.rb index 030b25f10..100515003 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -32,6 +32,14 @@ class Event < ApplicationRecord before_save :geocoding_cache_lookup, if: :address_will_change? after_save :enqueue_geocoding_worker, if: :address_changed? + APPROVAL_STATUS = { + 0 => :not_approved, + 1 => :requested, + 2 => :approved + }.freeze + + APPROVAL_STATUS_CODES = APPROVAL_STATUS.invert.freeze + if TeSS::Config.solr_enabled # :nocov: searchable do @@ -122,6 +130,9 @@ class Event < ApplicationRecord string :instructors, multiple: true do instructors.map(&:display_name) end + string :approval_status do + I18n.t("events.approval_status.#{approval_status}") + end end # :nocov: end @@ -162,6 +173,11 @@ class Event < ApplicationRecord validate :allowed_url validates :origin_uri, url: { allow_blank: true } + validates :approval_status, inclusion: { in: APPROVAL_STATUS.values } + before_create :set_approval_status + before_update :log_approval_status_change + before_update :reset_approval_status, if: :approval_required_and_content_changed? + clean_array_fields(:keywords, :fields, :event_types, :target_audience, :eligibility, :host_institutions, :sponsors) update_suggestions(:keywords, :target_audience, :host_institutions) @@ -217,7 +233,7 @@ def expired? def self.facet_fields field_list = %w[ content_provider keywords scientific_topics operations tools fields online event_types start venue city country organizer sponsors target_audience eligibility language - user node collections contributors instructors] + user node collections contributors instructors approval_status] field_list.delete('operations') if TeSS::Config.feature['disabled'].include? 'operations' field_list.delete('scientific_topics') if TeSS::Config.feature['disabled'].include? 'topics' @@ -568,6 +584,44 @@ def to_oai_dc xml.target! end + def self.approved + where(approval_status: APPROVAL_STATUS_CODES[:approved]) + end + + def self.approval_requested + where(approval_status: APPROVAL_STATUS_CODES[:requested]) + end + + def approval_status + APPROVAL_STATUS[super.to_i] || APPROVAL_STATUS[0] + end + + def approval_status=(key) + super(APPROVAL_STATUS_CODES[key.to_sym]) + end + + def not_approved? + approval_status == :not_approved + end + + def approved? + approval_status == :approved + end + + def approval_requested? + approval_status == :requested + end + + def request_approval + self.approval_status = :requested + save! + # CurationMailer.materials_require_approval(self, User.current_user).deliver_later + end + + def self.approval_required? + TeSS::Config.feature['event_under_admin_approval'] && !User.current_user&.is_admin? + end + private def allowed_url @@ -626,4 +680,40 @@ def fix_keywords def presence_default self.presence = :onsite if presence.blank? end + + def set_approval_status + # sets to `:approved` when the feature is off + unless TeSS::Config.feature['event_under_admin_approval'] + self.approval_status = :approved + end + # sets to `:requested` by default when the feature is on + if self.class.approval_required? + self.approval_status = :requested + end + end + + def approval_required_and_content_changed? + return false unless self.class.approval_required? + return false if approval_status_changed? # Avoid triggering on status changes itself + + # Ignore standard system/audit columns + ignored_columns = %w[updated_at created_at approval_status user_id] + (changed - ignored_columns).any? + end + + def reset_approval_status + self.approval_status = :requested + end + + def log_approval_status_change + if approval_status_changed? + old = (APPROVAL_STATUS[approval_status_before_last_save.to_i] || APPROVAL_STATUS[0]).to_s + new = approval_status.to_s + create_activity(:approval_status_changed, owner: User.current_user, parameters: { old: old, new: new }) + end + end + + def loggable_changes + super - %w[approval_status] + end end diff --git a/app/models/material.rb b/app/models/material.rb index 934edff78..824419426 100644 --- a/app/models/material.rb +++ b/app/models/material.rb @@ -23,6 +23,14 @@ class Material < ApplicationRecord include InSpace include HasPeople + APPROVAL_STATUS = { + 0 => :not_approved, + 1 => :requested, + 2 => :approved + }.freeze + + APPROVAL_STATUS_CODES = APPROVAL_STATUS.invert.freeze + if TeSS::Config.solr_enabled # :nocov: searchable do @@ -94,6 +102,9 @@ class Material < ApplicationRecord string :status do MaterialStatusDictionary.instance.lookup_value(status, 'title') end + string :approval_status do + I18n.t("materials.approval_status.#{approval_status}") + end end # :nocov: end @@ -125,6 +136,11 @@ class Material < ApplicationRecord validates :keywords, length: { maximum: 20 } validates :origin_uri, url: { allow_blank: true } + validates :approval_status, inclusion: { in: APPROVAL_STATUS.values } + before_create :set_approval_status + before_update :log_approval_status_change + before_update :reset_approval_status, if: :approval_required_and_content_changed? + clean_array_fields(:keywords, :fields, :target_audience, :resource_type, :subsets) @@ -147,7 +163,7 @@ def long_description=(desc) def self.facet_fields field_list = %w[scientific_topics operations tools standard_database_or_policy content_provider keywords difficulty_level fields licence target_audience authors contributors resource_type - related_resources user node collections status] + related_resources user node collections status approval_status] field_list.delete('operations') if TeSS::Config.feature['disabled'].include? 'operations' field_list.delete('scientific_topics') if TeSS::Config.feature['disabled'].include? 'topics' @@ -258,4 +274,80 @@ def to_oai_dc end xml.target! end + + def self.approved + where(approval_status: APPROVAL_STATUS_CODES[:approved]) + end + + def self.approval_requested + where(approval_status: APPROVAL_STATUS_CODES[:requested]) + end + + def approval_status + APPROVAL_STATUS[super.to_i] || APPROVAL_STATUS[0] + end + + def approval_status=(key) + super(APPROVAL_STATUS_CODES[key.to_sym]) + end + + def not_approved? + approval_status == :not_approved + end + + def approved? + approval_status == :approved + end + + def approval_requested? + approval_status == :requested + end + + def request_approval + self.approval_status = :requested + save! + # CurationMailer.materials_require_approval(self, User.current_user).deliver_later + end + + def self.approval_required? + TeSS::Config.feature['material_under_admin_approval'] && !User.current_user&.is_admin? + end + + private + + def set_approval_status + # sets to `:approved` when the feature is off + unless TeSS::Config.feature['material_under_admin_approval'] + self.approval_status = :approved + end + # sets to `:requested` by default when the feature is on + if self.class.approval_required? + self.approval_status = :requested + end + end + + def approval_required_and_content_changed? + return false unless self.class.approval_required? + return false if approval_status_changed? # Avoid triggering on status changes itself + + # Ignore standard system/audit columns + ignored_columns = %w[updated_at created_at approval_status user_id] + (changed - ignored_columns).any? + end + + def reset_approval_status + self.approval_status = :requested + end + + def log_approval_status_change + if approval_status_changed? + old = (APPROVAL_STATUS[approval_status_before_last_save.to_i] || APPROVAL_STATUS[0]).to_s + new = approval_status.to_s + create_activity(:approval_status_changed, owner: User.current_user, parameters: { old: old, new: new }) + end + end + + def loggable_changes + super - %w[approval_status] + end end diff --git a/app/models/user.rb b/app/models/user.rb index dea538778..1fe9de2e3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -135,6 +135,10 @@ def is_curator? self.has_role?('curator') end + def can_approve_resources? + self.is_admin? || self.is_curator? || has_role_in_any_space?(:admin) + end + def maintained_collections Collection.left_outer_joins(:collaborators).where(collaborators: { id: id }).or(Collection.where(user_id: id)).distinct end diff --git a/app/policies/application_policy.rb b/app/policies/application_policy.rb index d4bcb5ac8..2136eee71 100644 --- a/app/policies/application_policy.rb +++ b/app/policies/application_policy.rb @@ -90,34 +90,41 @@ def curators_and_admin user_has_role?(:curator, :admin, :scraper_user) end + def user_can_approve_resources? + @user&.can_approve_resources? + end + # Returns:: the default Pundit policy scope for the record's class. def scope Pundit.policy_scope!(user, record.class) end - # Determines whether the record should be visible to the current user, - # based on the private/public status of its associated space. +# Determines whether the record should be visible to the current user, + # considering space privacy and resource approval status. # # Rules: - # * if the record has no associated space, it is always shown; - # * if the associated space is not private, it is always shown; - # * otherwise, an authenticated user is shown the record only if they are - # an admin, or belong to at least one of the space's groups (and only - # when the space in question is the current space, or the record *is* - # the space itself). + # * Site administrator privileges: Always visible. + # * Space accessibility: + # - Public records or records outside private spaces are accessible. + # - Private space records are accessible to authenticated site admins, + # space admins, or members of the space's assigned groups. + # * Approval status (when approval feature is enabled): + # - Approved records follow standard space accessibility rules. + # - Unapproved records require BOTH space accessibility AND one of: + # * Site administrator privileges; + # * Curator privileges for public/default-space records; + # * Record ownership (@record.user_id == @user.id); + # * Space administrator privileges for the record's space. # # Returns:: +true+ or +false+. def shown? - return true if @space == nil - return true if !@space.is_private - return false unless @user # and so if space is private - if @space == Space.current_space || @record == @space - user_groups = @user.groups.pluck(:id) - space_groups = @space.groups.pluck(:id) - return @user.is_admin? || @user.groups.where(id: @space.groups).any? - end + return true if @user&.is_admin? # Resource is always shown to admin + return false unless space_accessible? # If the resource can be shown in the space continue + return true unless approval_enabled? && # If there is the approval feature enabled continue + @record.respond_to?(:approved?) && + !@record.approved? - return false + unapproved_accessible? # Who can still access the unapproved resource end # Default Pundit policy scope class. @@ -139,6 +146,35 @@ def initialize(context, scope) def resolve scope end + + # Determines the database scope of records a user is authorized to curate/approve. + # + # Rules: + # * Site administrators: All records across all spaces. + # * Curators: Records in public spaces (or with no space) + private spaces where they hold space admin roles. + # * Space administrators: Records strictly within their assigned spaces. + # * Non-admin / Regular users: No records (empty relation). + # + # Returns:: +ActiveRecord::Relation+. + def resolve_curatable + return scope.none unless user + return scope.all if user.is_admin? + + return scope.none unless scope.reflect_on_association(:space) + + admin_space_ids = user.space_roles.where(key: 'admin').pluck(:space_id) + + if user.is_curator? + # Public spaces (or no space) + private spaces where user is admin + scope.left_outer_joins(:space) + .where(spaces: { is_private: [false, nil] }) + .or(scope.where(space_id: admin_space_ids)) + else + # Space admins can only see their explicit spaces + return scope.none if admin_space_ids.empty? + scope.where(space_id: admin_space_ids) + end + end end private @@ -166,7 +202,41 @@ def scraper? def user_has_role?(*roles) return false if @user.nil? roles.any? { |r| @user.has_role?(r) } || - (@space && roles.any? { |r| @user.has_space_role?(@space, r) }) + (@space && roles.any? { |r| @user&.has_space_role?(@space, r) }) end + # Can the resource be shown in the space? + def space_accessible? + return true if @user&.has_space_role?(@space, 'admin') # Continue if it is the admin space + return true if @space == nil # Continue if we're in the default space + return true if !@space.is_private # Continue if the space is not private + return false unless @user + if @space == Space.current_space || @record == @space + return @user.groups.where(id: @space.groups).any? # Continue if the user is part of the space + end + + return false + end + + def approval_enabled? + case @record + when Material + TeSS::Config.feature['material_under_admin_approval'] + when Event + TeSS::Config.feature['event_under_admin_approval'] + else + false + end + end + + # Who can still access the resource when it is unapproved? + def unapproved_accessible? + return false unless @user + + space = @record.respond_to?(:space) ? @record.space : @space + + return true if @user.is_curator? && (space.nil? || !space.is_private) # Curators can see unapproved public resources + return true if @record.respond_to?(:user_id) && @record.user_id == @user.id # Resource can be shown to its owner + return true if space.present? && @user&.has_space_role?(space, 'admin') # Resource can be shown to its space admin + end end \ No newline at end of file diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index a6510c3c5..0b4cc9d0d 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -1,5 +1,29 @@ class EventPolicy < ScrapedResourcePolicy + class Scope < ApplicationPolicy::Scope + def resolve + return scope unless TeSS::Config.feature['event_under_admin_approval'] + return scope if user&.is_admin? + approved_scope = scope.approved + return approved_scope unless user + + user_conditions = scope.where(user_id: user.id) + + if scope.reflect_on_association(:space) + admin_space_ids = user.space_roles.where(key: 'admin').pluck(:space_id) + user_conditions = user_conditions.or(scope.where(space_id: admin_space_ids)) if admin_space_ids.any? + if user.is_curator? + public_space_ids = Space.where(is_private: false).pluck(:id) + user_conditions = user_conditions.or(scope.where(space_id: [nil, *public_space_ids])) + end + end + + approved_scope.or(user_conditions) + end + end + + # Determines whether the record should be visible to the current user, + # considering space privacy and resource approval status. def show? super && shown? end @@ -13,7 +37,35 @@ def view_report? end def clone? - manage? + manage? || curators_and_admin + end + + # For the edit and clone buttons + def update? + manage? || curators_and_admin || user&.has_space_role?(record.space, :admin) end + # For the approve rights + def approve? + approval_enabled? && curatable_by_user? + end + + def request_approval? + approval_enabled? && curatable_by_user? + end + + private + + def approval_enabled? + TeSS::Config.feature['event_under_admin_approval'] + end + + def curatable_by_user? + return false unless user + return true if user.is_admin? + return true if user.has_space_role?(record.space, 'admin') + return true if user.is_curator? && (record.space.nil? || !record.space.is_private) + + false + end end diff --git a/app/policies/material_policy.rb b/app/policies/material_policy.rb index f45c95bb3..81ce3f345 100644 --- a/app/policies/material_policy.rb +++ b/app/policies/material_policy.rb @@ -1,11 +1,63 @@ class MaterialPolicy < ScrapedResourcePolicy + class Scope < ApplicationPolicy::Scope + def resolve + return scope unless TeSS::Config.feature['material_under_admin_approval'] + return scope if user&.is_admin? + approved_scope = scope.approved + return approved_scope unless user + + user_conditions = scope.where(user_id: user.id) + + if scope.reflect_on_association(:space) + admin_space_ids = user.space_roles.where(key: 'admin').pluck(:space_id) + user_conditions = user_conditions.or(scope.where(space_id: admin_space_ids)) if admin_space_ids.any? + if user.is_curator? + public_space_ids = Space.where(is_private: false).pluck(:id) + user_conditions = user_conditions.or(scope.where(space_id: [nil, *public_space_ids])) + end + end + + approved_scope.or(user_conditions) + end + end + + # Determines whether the record should be visible to the current user, + # considering space privacy and resource approval status. def show? super && shown? end def clone? - manage? + manage? || curators_and_admin + end + + # For the edit and clone buttons + def update? + manage? || curators_and_admin || user&.has_space_role?(record.space, :admin) end + # To check if it has approval rights + def approve? + approval_enabled? && curatable_by_user? + end + + def request_approval? + approval_enabled? && curatable_by_user? + end + + private + + def approval_enabled? + TeSS::Config.feature['material_under_admin_approval'] + end + + def curatable_by_user? + return false unless user + return true if user.is_admin? + return true if user.has_space_role?(record.space, 'admin') + return true if user.is_curator? && (record.space.nil? || !record.space.is_private) + + false + end end \ No newline at end of file diff --git a/app/views/curator/_resource_card.html.erb b/app/views/curator/_resource_card.html.erb new file mode 100644 index 000000000..96fa9f35d --- /dev/null +++ b/app/views/curator/_resource_card.html.erb @@ -0,0 +1,76 @@ +
+
+ <%= resource.class.model_name.human %> + <%= link_to resource.title, resource, target: '_blank' %> + + <%= respond_to?("#{resource.model_name.singular}_approval_badge") ? + send("#{resource.model_name.singular}_approval_badge", resource.approval_status) : + resource_approval_badge(resource.approval_status) %> + +
+ <% unless resource.approved? %> + <%= link_to 'Approve', + polymorphic_path(resource, + resource.class.model_name.param_key => { approval_status: 'approved' }, + redirect_to: request.fullpath), + method: :put, + class: 'btn btn-xs btn-success', + data: { confirm: 'Approve this resource?' } %> + <% end %> + <% unless resource.not_approved? %> + <%= link_to 'Reject', + polymorphic_path(resource, + resource.class.model_name.param_key => { approval_status: 'not_approved' }, + redirect_to: request.fullpath), + method: :put, + class: 'btn btn-xs btn-danger', + data: { confirm: 'Reject this resource?' } %> + <% end %> +
+
+ +
+
+
+

+ Created <%= time_ago_in_words(resource.created_at) %> ago + <% if resource.user %> + by <%= link_to resource.user.username, resource.user, target: '_blank' %> + <% end %> + <% if resource.respond_to?(:content_provider) && resource.content_provider %> + | Provider: <%= link_to resource.content_provider.title, resource.content_provider, target: '_blank' %> + <% end %> + <% if resource.respond_to?(:space) && resource.space %> + | + <% if resource.space.is_private %> + + Private + <% end %> + Space: <%= link_to resource.space.title, space_path(resource.space), target: '_blank' %> + <% else %> + | Space: Default + <% end %> +

+ + <% if resource.respond_to?(:url) && resource.url.present? %> +

URL: <%= link_to resource.url, resource.url, target: '_blank', rel: 'nofollow noreferrer' %>

+ <% end %> + + <% if resource.respond_to?(:short_description) && resource.short_description.present? %> +

<%= truncate(resource.short_description, length: 200) %>

+ <% elsif resource.respond_to?(:description) && resource.description.present? %> +

<%= truncate(strip_tags(resource.description), length: 200) %>

+ <% end %> +
+ +
+ + Last updated: <%= time_ago_in_words(resource.updated_at) %> ago
+ <% if resource.respond_to?(:scraper_record) && resource.scraper_record? %> + Ingested Record + <% end %> +
+
+
+
+
\ No newline at end of file diff --git a/app/views/curator/materials.html.erb b/app/views/curator/materials.html.erb new file mode 100644 index 000000000..f803c9026 --- /dev/null +++ b/app/views/curator/materials.html.erb @@ -0,0 +1,62 @@ +<% filter_params = params.permit(:status, :content_provider_id).to_h %> + + + +
+ +
+
+
Recent Approval Activity
+
+ <% activities = recent_resource_approvals %> + <% if activities.any? %> +
    + <% activities.each do |activity| %> + <% next unless activity.trackable %> +
  • + <%= link_to activity.trackable.title, activity.trackable, target: '_blank' %>
    + <%= resource_approval_badge(activity.parameters[:new]) %> + + by <%= activity.owner.try(:username) || 'System' %> <%= time_ago_in_words(activity.created_at) %> ago + +
  • + <% end %> +
+ <% else %> + No recent approval activity. + <% end %> +
+
+
+ + +
+ <% if @materials.any? %> + <%= render partial: 'curator/resource_card', collection: @materials, as: :resource %> + <%= render partial: 'search/common/pagination_bar', locals: { resources: @materials } %> + <% else %> +
No materials found matching this criteria.
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/curator/resources.html.erb b/app/views/curator/resources.html.erb new file mode 100644 index 000000000..ac37b0fcb --- /dev/null +++ b/app/views/curator/resources.html.erb @@ -0,0 +1,146 @@ +<% filter_params = params.permit(:status, :type, :content_provider_id, :space_id, :user_id).to_h %> +<% is_global = current_user.is_admin? || current_user.is_curator? %> + + + +
+ +
+
+
Recent Approval Activity
+
+ <% activities = recent_resource_approvals %> + <% if activities.any? %> +
    + <% activities.each do |activity| %> + <% resource = activity.trackable %> + <% next unless resource %> +
  • + <%= activity.trackable_type %> + <%= link_to resource.title, resource, target: '_blank' %> + + <% if TeSS::Config.feature['sources'] && resource.respond_to?(:content_provider) && resource.content_provider %> + | Provider: <%= link_to resource.content_provider.title, resource.content_provider, target: '_blank' %> + <% end %> + + <% if TeSS::Config.feature['spaces'] %> + <% if resource.respond_to?(:space) && resource.space %> + | + <% if resource.space.is_private %> + + Private + <% end %> + Space: <%= link_to resource.space.title, space_path(resource.space), target: '_blank' %> + <% else %> + | Space: Default + <% end %> + <% end %> +
    + <% + raw_params = activity.parameters + params_hash = if raw_params.is_a?(Hash) + raw_params.with_indifferent_access + elsif raw_params.is_a?(String) + (JSON.parse(raw_params) rescue YAML.safe_load(raw_params) rescue {}).with_indifferent_access + else + {} + end + %> + <%= resource_approval_badge(params_hash[:new]) %> + + by <%= activity.owner.try(:username) || 'System' %> <%= time_ago_in_words(activity.created_at) %> ago + +
  • + <% end %> +
+ <% else %> + No recent approval activity. + <% end %> +
+
+
+ + +
+ <% if @resources.any? %> + <%= render partial: 'curator/resource_card', collection: @resources, as: :resource %> + <%= render partial: 'search/common/pagination_bar', locals: { resources: @resources } %> + <% else %> +
No resources found matching this criteria.
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/events/_event.html.erb b/app/views/events/_event.html.erb index 8bf1fd2b0..cd1eb43ed 100644 --- a/app/views/events/_event.html.erb +++ b/app/views/events/_event.html.erb @@ -21,6 +21,8 @@ <%= event_status_icon(event) %> <%= exchange_icon(event) %> + <%= approval_requested_icon(event) %> + <%= not_approved_icon(event) %> <% if event.event_types.any? %> <% event.event_types.each do |t| %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 7dbb92a6b..ef7836c04 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -181,6 +181,16 @@ <%= render partial: "common/people_form", locals: { f: f, field_name: 'instructors', role: 'instructor' } %> + + <% if policy(@event).approve? %> + <%= f.input :approval_status, + label: 'Approval Status', + collection: approval_options_for_select_event, + include_blank: false %> + <% end %> + +
+
<%= f.submit(class: 'btn btn-primary') %> <%= link_to 'Back', :back, class: 'btn btn-default' %> diff --git a/app/views/events/index.html.erb b/app/views/events/index.html.erb index cab70d890..dc1ee3ea5 100644 --- a/app/views/events/index.html.erb +++ b/app/views/events/index.html.erb @@ -1,3 +1,5 @@ +<% can_approve_resources = current_user&.can_approve_resources? %> +
<% if TeSS::Config.solr_enabled %>
+ <% if TeSS::Config.feature['event_under_admin_approval'] %> + <% unless policy(@event).approve? %> +
<%= t('events.info.approval_notice') %>

+ <% end %> + <% end %> + <%= render :partial => 'form' %>
diff --git a/app/views/events/show.html.erb b/app/views/events/show.html.erb index e263ed2b8..b7171a141 100644 --- a/app/views/events/show.html.erb +++ b/app/views/events/show.html.erb @@ -48,6 +48,17 @@
<% if @event.persisted? %>
+ <% if policy(@event).update? %> +

+ Approval status: + <%= resource_approval_badge(@event.approval_status) %> +

+ + <% if policy(@event).approve? && !@event.approved? %> + <%= link_to 'Approve', event_path(@event, event: { approval_status: 'approved' }), + method: :put, class: 'btn btn-success' %> + <% end %> + <% end %> <% if policy(@event).update? %> <%= link_to 'Clone', clone_event_path(@event), class: 'btn btn-default' %> <%= edit_button(@event) %> diff --git a/app/views/layouts/_user_menu.html.erb b/app/views/layouts/_user_menu.html.erb index 89edda352..94c6bab7f 100644 --- a/app/views/layouts/_user_menu.html.erb +++ b/app/views/layouts/_user_menu.html.erb @@ -20,7 +20,8 @@ <% is_curator = current_user.is_curator? %> <% is_owner_in_any_group = current_user.is_owner_in_any_group? %> <% is_current_space_admin = !Space.current_space.default? && current_user.has_space_role?(Space.current_space, :admin) %> - <% if is_admin || is_curator || is_current_space_admin || is_owner_in_any_group %> + <% is_space_admin = current_user.has_role_in_any_space?(:admin) %> + <% if is_admin || is_curator || is_current_space_admin || is_space_admin || is_owner_in_any_group %> @@ -33,7 +34,7 @@ <% end %> - <% if TeSS::Config.feature['sources'] %> + <% if TeSS::Config.feature['sources'] && !is_space_admin %> <% end %> + <% if (TeSS::Config.feature['material_under_admin_approval'] || TeSS::Config.feature['event_under_admin_approval']) && (is_admin || is_curator || is_space_admin) %> + + <% end %> + <% if is_admin %>