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,20 @@
# frozen_string_literal: true
class Course::AssessmentMarketplaceComponent < SimpleDelegator
include Course::ControllerComponentHost::Component

def self.display_name
'Assessment Marketplace'
end

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
22 changes: 9 additions & 13 deletions app/controllers/components/course/gradebook_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,22 @@ def main_sidebar_items
return [] unless can?(:read_gradebook, current_course)

[
{
key: self.class.key,
icon: :gradebook,
type: :normal,
weight: 9,
path: course_gradebook_path(current_course)
}
key: self.class.key,
icon: :gradebook,
type: :normal,
weight: 9,
path: course_gradebook_path(current_course)
]
end

def settings_sidebar_items
return [] unless can?(:manage_gradebook_settings, current_course)

[
{
key: self.class.key,
type: :settings,
weight: 14,
path: course_admin_gradebook_path(current_course)
}
key: self.class.key,
type: :settings,
weight: 14,
path: course_admin_gradebook_path(current_course)
]
end
end
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,82 @@
# 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. The source course is deliberately NOT preloaded: the MVP exposes no
# attribution, so nothing in the view reaches for it.
@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(
listings.map(&:id), current_course, duplicate_params[:destination_tab_id].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,42 @@
# 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
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
4 changes: 2 additions & 2 deletions app/controllers/course/statistics/aggregate_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# frozen_string_literal: true
# This is named aggregate controller as naming this as course controller leads to name conflict issues
class Course::Statistics::AggregateController < Course::Statistics::Controller
class Course::Statistics::AggregateController < Course::Statistics::Controller # rubocop:disable Metrics/ClassLength
before_action :preload_levels, only: [:all_students, :course_performance]
include Course::Statistics::TimesConcern
include Course::Statistics::GradesConcern
Expand Down 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
54 changes: 54 additions & 0 deletions app/jobs/course/assessment/marketplace/duplication_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# 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)
listings.each do |listing|
copy = duplicate_listing(listing, destination_course, current_user)
reparent_into_tab(copy, destination_course, destination_tab_id)
record_adoption(listing, destination_course, copy, current_user)
end
redirect_to course_assessments_url(destination_course,
category: destination_course.assessment_categories.first.id,
tab: destination_tab_id,
host: destination_course.instance.host)
end
end

private

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, destination_course, destination_tab_id)
target_tab = destination_course.assessment_categories.
flat_map(&:tabs).find { |tab| tab.id == destination_tab_id }
return unless target_tab && copy.tab_id != target_tab.id

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

def record_adoption(listing, destination_course, copy, current_user)
Course::Assessment::Marketplace::Adoption.create!(
listing: listing,
destination_course: destination_course,
duplicated_assessment: copy,
creator: current_user,
updater: current_user
)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true
module Course::AssessmentMarketplaceAbilityComponent
include AbilityHost::Component

def define_permissions
allow_admins_publish_to_marketplace if user&.administrator?
allow_managers_access_marketplace if course_user&.manager_or_owner?
super
end

private

def allow_admins_publish_to_marketplace
can :publish_to_marketplace, Course::Assessment
end

def allow_managers_access_marketplace
can :access_marketplace, Course, id: course.id
can :duplicate_from_marketplace, Course::Assessment do |assessment|
assessment.marketplace_listing&.published? || false
end
can :preview_in_marketplace, Course::Assessment do |assessment|
assessment.marketplace_listing&.published? || false
end
end
end
2 changes: 2 additions & 0 deletions app/models/course/assessment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ class Course::Assessment < ApplicationRecord
has_one :gradebook_assessment_contribution,
class_name: 'Course::Gradebook::AssessmentContribution',
dependent: :destroy, inverse_of: :assessment
has_one :marketplace_listing, class_name: 'Course::Assessment::Marketplace::Listing',
inverse_of: :assessment, dependent: :destroy
has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback',
inverse_of: :assessment, dependent: :destroy
has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy
Expand Down
6 changes: 6 additions & 0 deletions app/models/course/assessment/marketplace.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# frozen_string_literal: true
module Course::Assessment::Marketplace
def self.table_name_prefix
'course_assessment_marketplace_'
end
end
10 changes: 10 additions & 0 deletions app/models/course/assessment/marketplace/adoption.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::Adoption < ApplicationRecord
belongs_to :listing, class_name: 'Course::Assessment::Marketplace::Listing', inverse_of: :adoptions
belongs_to :destination_course, class_name: 'Course', inverse_of: false
belongs_to :duplicated_assessment, class_name: 'Course::Assessment', inverse_of: false

validates :duplicated_assessment_id, uniqueness: true
validates :creator, presence: true
validates :updater, presence: true
end
18 changes: 18 additions & 0 deletions app/models/course/assessment/marketplace/listing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::Listing < ApplicationRecord
belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing
belongs_to :publisher, class_name: 'User', inverse_of: false
has_many :adoptions, class_name: 'Course::Assessment::Marketplace::Adoption',
inverse_of: :listing, dependent: :destroy

validates :assessment_id, uniqueness: true
validates :publisher, presence: true
validates :creator, presence: true
validates :updater, presence: true

scope :published, -> { where(published: true) }

def adoption_count
adoptions.distinct.count(:destination_course_id)
end
end
4 changes: 4 additions & 0 deletions app/views/course/assessment/assessments/show.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,12 @@ json.permissions do
json.canManage can_manage
json.canObserve can_observe
json.canInviteToKoditsu can?(:invite_to_koditsu, assessment)
json.canPublishToMarketplace((can?(:publish_to_marketplace, @assessment) && current_user&.administrator?) || false)
end

json.isPublishedToMarketplace @assessment.marketplace_listing&.published? || false
json.marketplaceListingUrl course_assessment_marketplace_listing_path(current_course, @assessment)

unless can_attempt
not_started_for_user = assessment_not_started(assessment.time_for(current_course_user))
json.willStartAt assessment.time_for(current_course_user).start_at if not_started_for_user
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# frozen_string_literal: true
json.canAccess true
json.listings @listings do |listing|
assessment = listing.assessment
json.id listing.id
json.assessmentId assessment.id
json.title assessment.title
json.questionCount(@question_counts[assessment.id] || 0)
json.adoptions(@adoption_counts[listing.id] || 0)
json.firstPublishedAt listing.first_published_at
json.previewUrl course_listing_path(current_course, listing)
json.duplicateUrl duplicate_course_listings_path(current_course)
end
json.destinationTabs @destination_tabs do |tab|
json.id tab[:id]
json.title tab[:title]
json.categoryId tab[:category_id]
json.categoryTitle tab[:category_title]
end
Loading