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
Expand Up @@ -11,7 +11,7 @@ def sidebar_items

[
key: :admin_marketplace,
icon: :duplication,
icon: :marketplace,
type: :admin,
weight: 6,
path: course_marketplace_path(current_course)
Expand Down
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
4 changes: 4 additions & 0 deletions app/controllers/course/assessment/marketplace/controller.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,14 @@ class Course::Assessment::Marketplace::ListingsController < Course::Assessment::

def index
ActsAsTenant.without_tenant do
@listings = Course::Assessment::Marketplace::Listing.published.includes(:assessment).to_a
listing_ids = @listings.map(&:id)
assessment_ids = @listings.map(&:assessment_id)
@adoption_counts = Course::Assessment::Marketplace::Adoption.
where(listing_id: listing_ids).group(:listing_id).
distinct.count(:destination_course_id)
# 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).
@question_counts = Course::QuestionAssessment.
where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id).
distinct.count(:question_id)
# 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

Expand All @@ -28,12 +24,46 @@ def duplicate
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)
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)
Expand Down
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
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ json.listings @listings do |listing|
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true
json.id @assessment.id
json.title @assessment.title
json.description format_ckeditor_rich_text(@assessment.description)

json.gradingMode @assessment.autograded? ? 'autograded' : 'manual'
json.baseExp @assessment.base_exp if @assessment.base_exp > 0
json.bonusExp @assessment.time_bonus_exp if @assessment.time_bonus_exp > 0
json.showMcqMrqSolution @assessment.show_mcq_mrq_solution
json.showRubricToStudents @assessment.show_rubric_to_students
json.gradedTestCases display_graded_test_types(@assessment)

questions = @assessment.questions.includes(:actable)

# Group by the human-readable type (e.g. "Multiple Choice", "Text Response Question") so the
# breakdown matches the per-question chips and the wording of the real assessment show page,
# instead of raw actable class names ("MultipleResponse").
json.typeCounts questions.group_by(&:question_type_readable).transform_values(&:size)

json.questions questions do |question|
json.id question.id
json.title question.title
json.description format_ckeditor_rich_text(question.description)
json.staffOnlyComments format_ckeditor_rich_text(question.staff_only_comments)
json.maximumGrade question.maximum_grade
# Human-readable label for the type chip, mirroring _question_assessment.json.jbuilder. The
# renderer dispatch lives on the detail endpoint (which keeps the demodulized discriminator).
json.type question.question_type_readable
json.unautogradable !question.auto_gradable?

if question.actable_type == 'Course::Assessment::Question::MultipleResponse'
mrq = question.actable
json.mcqMrqType mrq.multiple_choice? ? 'mcq' : 'mrq' # multiple_choice? is aliased to any_correct?
json.options mrq.options do |option|
json.id option.id
json.option format_ckeditor_rich_text(option.option)
json.correct option.correct
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# frozen_string_literal: true
json.maxPosts question.max_posts
json.hasTextResponse question.has_text_response
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true
json.gradingScheme question.grading_scheme
json.options question.options do |option|
json.id option.id
json.option format_ckeditor_rich_text(option.option)
json.correct option.correct
json.explanation format_ckeditor_rich_text(option.explanation)
json.weight option.weight
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true
json.languageName question.language&.name
json.memoryLimit question.memory_limit
json.timeLimit question.time_limit

json.templateFiles question.template_files do |file|
json.filename file.filename
json.content file.content
end

grouped = question.test_cases.group_by(&:test_case_type)
{ 'publicTestCases' => 'public_test',
'privateTestCases' => 'private_test',
'evaluationTestCases' => 'evaluation_test' }.each do |key, type|
json.set! key, (grouped[type] || []) do |tc|
json.identifier tc.identifier
json.expression tc.expression
json.expected tc.expected
json.hint tc.hint
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# frozen_string_literal: true
json.categories question.categories do |category|
json.name category.name
json.isBonus category.is_bonus_category
json.criteria category.criterions do |criterion|
json.grade criterion.grade
json.explanation format_ckeditor_rich_text(criterion.explanation)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true
# Verified against app/views/course/assessment/question/scribing/_scribing_question.json.jbuilder:
# scribing exposes its image via `attachment_reference.generate_public_url`, guarded by presence.
json.imageUrl question.attachment_reference&.generate_public_url
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# frozen_string_literal: true
json.hideText question.hide_text
json.isAttachmentRequired question.is_attachment_required
json.maxAttachments question.max_attachments
json.maxAttachmentSize question.max_attachment_size
json.isComprehension question.is_comprehension
json.solutions question.solutions do |solution|
json.solutionType solution.solution_type
json.solution format_ckeditor_rich_text(solution.solution)
json.grade solution.grade
json.explanation format_ckeditor_rich_text(solution.explanation)
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true
# Voice questions have no type-specific setup fields; the base prompt is shown by the shell.
# `json.merge!({})` forces the enclosing `json.detail do … end` block to serialize as an empty
# object `{}`. Without it the block's scope stays blank and jbuilder emits `null` instead.
json.merge!({})
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# frozen_string_literal: true
detail_partials = {
'Course::Assessment::Question::MultipleResponse' => 'multiple_response',
'Course::Assessment::Question::Programming' => 'programming',
'Course::Assessment::Question::TextResponse' => 'text_response',
'Course::Assessment::Question::RubricBasedResponse' => 'rubric_based_response',
'Course::Assessment::Question::ForumPostResponse' => 'forum_post_response',
'Course::Assessment::Question::VoiceResponse' => 'voice_response',
'Course::Assessment::Question::Scribing' => 'scribing'
}

json.id @question.id
json.title @question.title
json.defaultTitle @question_assessment.default_title(@question_assessment.question_number)
json.description format_ckeditor_rich_text(@question.description)
json.staffOnlyComments format_ckeditor_rich_text(@question.staff_only_comments)
json.maximumGrade @question.maximum_grade
# `type` is the demodulized discriminator that drives the frontend renderer dispatch; keep it stable.
json.type @question.actable_type.demodulize
# `displayType` is the human-readable label shown in the detail header chip (mirrors the card).
json.displayType @question.question_type_readable

partial = detail_partials[@question.actable_type]
if partial
json.detail do
json.partial! "course/assessment/marketplace/questions/details/#{partial}", question: @question.actable
end
else
json.detail nil
end
18 changes: 16 additions & 2 deletions client/app/api/course/Marketplace.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AxiosResponse } from 'axios';

import { MarketplaceListing } from 'course/marketplace/types';
import { DestinationTab, MarketplaceListing } from 'course/marketplace/types';

import BaseCourseAPI from './Base';

Expand All @@ -22,7 +22,11 @@ export default class MarketplaceAPI extends BaseCourseAPI {
}

index(): Promise<
AxiosResponse<{ listings: MarketplaceListing[]; canAccess: boolean }>
AxiosResponse<{
listings: MarketplaceListing[];
destinationTabs: DestinationTab[];
canAccess: boolean;
}>
> {
return this.client.get(this.#urlPrefix);
}
Expand All @@ -36,4 +40,14 @@ export default class MarketplaceAPI extends BaseCourseAPI {
...(destinationTabId ? { destination_tab_id: destinationTabId } : {}),
});
}

fetchListing(id: number): Promise<AxiosResponse> {
return this.client.get(`${this.#urlPrefix}/listings/${id}`);
}

fetchQuestion(listingId: number, questionId: number): Promise<AxiosResponse> {
return this.client.get(
`${this.#urlPrefix}/listings/${listingId}/questions/${questionId}`,
);
}
}
Loading