Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class Course::AssessmentMarketplaceComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def sidebar_items
return [] unless can?(:access_marketplace, current_course)

[
{
key: :admin_marketplace,
icon: :marketplace,
type: :admin,
weight: 6,
path: course_marketplace_path(current_course)
}
]
end
end
4 changes: 0 additions & 4 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@
class Course::GradebookComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def self.display_name
'Gradebook'
end

def sidebar_items
main_sidebar_items + settings_sidebar_items
end
Expand Down
12 changes: 12 additions & 0 deletions app/controllers/course/assessment/marketplace/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::Controller < Course::ComponentController
# display_graded_test_types is defined in Course::Assessment::AssessmentsHelper; the marketplace
# preview views reuse it, but Rails only auto-includes a controller's own matching helper.
helper Course::Assessment::AssessmentsHelper

private

def component
current_component_host[:course_assessment_marketplace_component]
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::ListingsController < Course::Assessment::Marketplace::Controller
before_action :authorize_access!

def index
ActsAsTenant.without_tenant do
# Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on
# the acting-as record.
@listings = Course::Assessment::Marketplace::Listing.published.
includes(assessment: :lesson_plan_item).to_a
@adoption_counts = adoption_counts(@listings.map(&:id))
@question_counts = question_counts(@listings.map(&:assessment_id))
@destination_tabs = destination_tabs
end
end

def duplicate
listings = authorized_listings
job = Course::Assessment::Marketplace::DuplicationJob.perform_later(
# `presence` first: an omitted tab (the sidebar entry point) must stay nil so the job lets the
# duplication fall back to the destination course's first tab, rather than looking for tab 0.
listings.map(&:id), current_course, duplicate_params[:destination_tab_id].presence&.to_i,
current_user: current_user
).job
render partial: 'jobs/submitted', locals: { job: job }
end

def show
ActsAsTenant.without_tenant do
@listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).find_by(id: params[:id])
raise CanCan::AccessDenied unless @listing

@assessment = @listing.assessment
authorize!(:preview_in_marketplace, @assessment)
@destination_tabs = destination_tabs
render 'show'
end
end

private

def authorize_access!
authorize!(:access_marketplace, current_course)
end

def adoption_counts(listing_ids)
Course::Assessment::Marketplace::Adoption.
where(listing_id: listing_ids).group(:listing_id).
distinct.count(:destination_course_id)
end

def question_counts(assessment_ids)
# reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it
# the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is
# neither grouped nor aggregated).
Course::QuestionAssessment.
where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id).
distinct.count(:question_id)
end

def destination_tabs
current_course.assessment_categories.includes(:tabs).flat_map do |category|
category.tabs.map do |tab|
{ id: tab.id, title: tab.title, category_id: category.id, category_title: category.title }
end
end
end

def authorized_listings
listings = ActsAsTenant.without_tenant do
Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).includes(:assessment)
end
raise CanCan::AccessDenied if listings.empty?

listings.each { |listing| authorize!(:duplicate_from_marketplace, listing.assessment) }
authorize!(:duplicate_to, current_course)
listings
end

def duplicate_params
params.permit(:destination_tab_id, listing_ids: [])
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::QuestionsController < Course::Assessment::Marketplace::Controller
before_action :authorize_access!

def show
ActsAsTenant.without_tenant do
listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).
find_by(id: params[:listing_id])
raise CanCan::AccessDenied unless listing

@assessment = listing.assessment
authorize!(:preview_in_marketplace, @assessment)

@question = @assessment.questions.includes(:actable).find(params[:id])
@question_assessment = @question.question_assessments.find_by!(assessment: @assessment)
render 'show' # rendered inside without_tenant so actable associations resolve cross-instance
end
end

private

def authorize_access!
authorize!(:access_marketplace, current_course)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true
class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller
before_action :authorize_publish_to_marketplace!

def create
listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment)
now = Time.zone.now
listing.published = true
listing.first_published_at ||= now
listing.last_published_at = now
# `publisher` is an audit userstamp for the *latest* publish (design D29), so it moves with
# `last_published_at`. `creator` already retains whoever first created the row.
listing.publisher = current_user
if listing.save
render json: { published: true }, status: :ok
else
render json: { errors: listing.errors.full_messages }, status: :unprocessable_content
end
end

def destroy
listing = @assessment.marketplace_listing
if listing&.update(published: false)
head :ok
else
head :unprocessable_content
end
end

private

# Publishing is admin-only. `authorize!(:publish_to_marketplace, @assessment)` alone is
# insufficient: teaching staff hold `can :manage, Course::Assessment` over their own course's
# assessments (assessment_ability.rb:189), and CanCan's `:manage` wildcard subsumes every
# custom action — including `:publish_to_marketplace`. Gate explicitly on administrator status.
def authorize_publish_to_marketplace!
authorize!(:publish_to_marketplace, @assessment)
raise CanCan::AccessDenied unless current_user&.administrator?
end

def component
current_component_host[:course_assessments_component]
end
end
2 changes: 1 addition & 1 deletion app/controllers/course/statistics/aggregate_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ def correctness_hash
id
SQL
)
query.map { |u| [u.id, u.correctness] }.to_h
query.to_h { |u| [u.id, u.correctness] }
end

def fetch_all_assessment_related_statistics_hash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAccessBlocksController < System::Admin::Controller
def create
block = Course::Assessment::Marketplace::AccessBlock.new(
user_id: params[:user_id], creator: current_user
)
if block.save
render json: { id: block.id, userId: block.user_id }, status: :ok
else
render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request
end
end

def destroy
block = Course::Assessment::Marketplace::AccessBlock.find(params[:id])
if block.destroy
head :ok
else
render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request
end
end
end
8 changes: 8 additions & 0 deletions app/controllers/system/admin/marketplace_access_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAccessController < System::Admin::Controller
def index
query = Course::Assessment::Marketplace::AccessListQuery.new
@rows = query.rows
@summary = query.summary
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true
class System::Admin::MarketplaceAllowlistRulesController < System::Admin::Controller
# `preview` is a collection action with no id, so CanCan's default loader would try
# `find(params[:id])`. It builds its own unsaved rule; System::Admin::Controller's
# `authorize_admin` already gates the whole controller.
load_and_authorize_resource :allowlist_rule,
class: 'Course::Assessment::Marketplace::AllowlistRule',
parent: false, except: [:preview]

def index
# "Everyone" is a page-level mode, not a table row: expose its presence as `@everyone_rule`
# and show only the scoped rules in the table.
@everyone_rule = @allowlist_rules.rule_type_everyone.first
@allowlist_rules = @allowlist_rules.where.not(rule_type: :everyone).includes(:user, :instance)
end

def create
if @allowlist_rule.save
# `render partial:` (not `render 'rule'`) — the view is the `_rule` partial. Mirrors
# System::Admin::AnnouncementsController#create (`render partial: '.../announcement_data'`).
render partial: 'rule', locals: { rule: @allowlist_rule }, status: :ok
else
render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request
end
end

def preview
rule = Course::Assessment::Marketplace::AllowlistRule.new(allowlist_rule_params)
unless rule.valid?
render json: { errors: rule.errors.full_messages.to_sentence }, status: :bad_request
return
end

query = Course::Assessment::Marketplace::RulePreviewQuery.new(rule)
@rows = query.rows
@summary = query.summary
end

def destroy
if @allowlist_rule.destroy
head :ok
else
render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request
end
end

private

def allowlist_rule_params
params.require(:allowlist_rule).permit(:rule_type, :user_id, :instance_id, :email_domain, :email)
end
end
13 changes: 13 additions & 0 deletions app/helpers/system/admin/marketplace_access_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module System::Admin::MarketplaceAccessHelper
# The value half of a rule's label ("Email domain · <value>"), for the audit list's reason column.
# @param [Course::Assessment::Marketplace::AllowlistRule] rule
# @return [String, nil]
def marketplace_rule_label_value(rule)
case rule.rule_type
when 'user' then rule.user&.name
when 'instance' then rule.instance&.name
when 'email_domain' then rule.email_domain
end
end
end
64 changes: 64 additions & 0 deletions app/jobs/course/assessment/marketplace/duplication_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::DuplicationJob < ApplicationJob
include TrackableJob
include Rails.application.routes.url_helpers

queue_as :duplication

protected

def perform_tracked(listing_ids, destination_course, destination_tab_id, options = {})
current_user = options[:current_user]
ActsAsTenant.without_tenant do
listings = Course::Assessment::Marketplace::Listing.published.where(id: listing_ids)
target_tab = find_tab(destination_course, destination_tab_id)
last_copy = nil
listings.each do |listing|
# The adoption row is written by the duplication service itself, which tracks every copy of a
# listed assessment regardless of the path that produced it. See
# `Course::Duplication::BaseService#record_marketplace_adoptions`.
last_copy = duplicate_listing(listing, destination_course, current_user)
reparent_into_tab(last_copy, target_tab)
end
redirect_to assessments_url(destination_course, target_tab || last_copy&.tab)
end
end

private

# @return [Course::Assessment::Tab, nil] The requested tab, or nil when no tab was requested or
# the requested one does not belong to the destination course.
def find_tab(destination_course, destination_tab_id)
return nil unless destination_tab_id

destination_course.assessment_categories.
flat_map(&:tabs).find { |tab| tab.id == destination_tab_id }
end

def duplicate_listing(listing, destination_course, current_user)
source = listing.assessment
Course::Duplication::ObjectDuplicationService.duplicate_objects(
source.course, destination_course, source, current_user: current_user
)
end

def reparent_into_tab(copy, target_tab)
return unless target_tab && copy.tab_id != target_tab.id

copy.tab = target_tab
copy.folder.parent = target_tab.category.folder
copy.save!
end

# Points at the tab the copies actually landed in. No tab is requested from the sidebar entry
# point, and a requested tab may not belong to the destination course -- in both cases the
# duplication picks the destination's default tab, and the redirect has to follow it there
# instead of naming a tab (and its category) that the user cannot open.
def assessments_url(destination_course, tab)
redirect_category_id = tab&.category_id || destination_course.assessment_categories.first.id
course_assessments_url(destination_course,
category: redirect_category_id,
tab: tab&.id,
host: destination_course.instance.host)
end
end
Loading