Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4e69714
feat(approval): added approval status for materials
kennethrioja Jan 16, 2026
80366f0
fix(approval): loop non-approved/request/approved is UI ok
kennethrioja Jan 19, 2026
4906cdb
chore(approval): set default and show requested icon
kennethrioja Jan 20, 2026
c0ad4dc
feat(approval): filter approval in curation section
kennethrioja Jan 26, 2026
226ca7e
fix(approval): show only approved material in lists
kennethrioja Jan 30, 2026
9aeaab8
fix(approval): sort by approval, also in latest
kennethrioja Jan 30, 2026
0cf0291
fix(approval): rendering and controller
kennethrioja May 21, 2026
b6d3854
chore(approval): change sunspot searchable for only approved materials
kennethrioja Sep 11, 2026
5d50d9d
chore(approval): make it work with material_under_admin_approval==false
kennethrioja Sep 11, 2026
b518e30
test(approval): passing base tests
kennethrioja Sep 11, 2026
586fe5f
test(approval): adding tests
kennethrioja Sep 14, 2026
c32e5b6
feat(approval): adding /curate/materials view
kennethrioja Sep 15, 2026
27f2e36
feat(approval): added events approval status, and a resource curation…
kennethrioja Sep 17, 2026
c7209f3
feat(approval): curators can have the same approval power and views t…
kennethrioja Sep 18, 2026
6f993b7
fix(approval): curators can not see private space materials where the…
kennethrioja Sep 18, 2026
6c2385c
refactor(approval): more readable code
kennethrioja Sep 28, 2026
5afb4c0
test(approval): adding tests for application_policy and curation
kennethrioja Sep 28, 2026
e1a6a30
chore(approval): few bits before doing the PR
kennethrioja Sep 28, 2026
aafb344
chore(approval): scss and before_update small tweaks
kennethrioja Sep 29, 2026
f86d0e9
fix(approval): renaming migration file title which didn't match class…
kennethrioja Sep 29, 2026
a2957c1
review(approval): addressing copilot's review from #1382
kennethrioja Oct 15, 2026
caf1167
fix(approval): curators not private space admins can access a private…
kennethrioja Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/assets/stylesheets/application.scss
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
@import "pages/sources";
@import "pages/workflows";
@import "groups";
@import "pages/curator";

// Vendor
@import "cytoscape-panzoom";
Expand Down
14 changes: 14 additions & 0 deletions app/assets/stylesheets/pages/curator.scss
Original file line number Diff line number Diff line change
@@ -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;
}
107 changes: 103 additions & 4 deletions app/controllers/curator_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
71 changes: 52 additions & 19 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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? }
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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}",
Expand All @@ -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
Expand Down
73 changes: 52 additions & 21 deletions app/controllers/materials_controller.rb
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 }
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
Loading
Loading