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
8 changes: 8 additions & 0 deletions app/controllers/course/assessment/marketplace/controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::Controller < Course::ComponentController
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,27 @@
# frozen_string_literal: true
class Course::Assessment::Marketplace::ListingsController < Course::Assessment::Marketplace::Controller
before_action :authorize_access!

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)
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
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,13 @@
# 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
29 changes: 29 additions & 0 deletions client/app/api/course/Marketplace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { AxiosResponse } from 'axios';

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

import BaseCourseAPI from './Base';

export default class MarketplaceAPI extends BaseCourseAPI {
get #urlPrefix(): string {
return `/courses/${this.courseId}/marketplace`;
}

publishListing(assessmentId: number): Promise<AxiosResponse> {
return this.client.post(
`/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`,
);
}

removeListing(assessmentId: number): Promise<AxiosResponse> {
return this.client.delete(
`/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`,
);
}

index(): Promise<
AxiosResponse<{ listings: MarketplaceListing[]; canAccess: boolean }>
> {
return this.client.get(this.#urlPrefix);
}
}
2 changes: 2 additions & 0 deletions client/app/api/course/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import LeaderboardAPI from './Leaderboard';
import LearningMapAPI from './LearningMap';
import LessonPlanAPI from './LessonPlan';
import LevelAPI from './Level';
import MarketplaceAPI from './Marketplace';
import MaterialFoldersAPI from './MaterialFolders';
import MaterialsAPI from './Materials';
import PersonalTimesAPI from './PersonalTimes';
Expand Down Expand Up @@ -55,6 +56,7 @@ const CourseAPI = {
learningMap: new LearningMapAPI(),
lessonPlan: new LessonPlanAPI(),
level: new LevelAPI(),
marketplace: new MarketplaceAPI(),
materials: new MaterialsAPI(),
materialFolders: new MaterialFoldersAPI(),
personalTimes: new PersonalTimesAPI(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
AssessmentDeleteResult,
} from 'types/course/assessment/assessments';

import PublishToMarketplaceButton from 'course/marketplace/components/PublishToMarketplaceButton';
import marketplaceTranslations from 'course/marketplace/translations';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import { PromptText } from 'lib/components/core/dialogs/Prompt';
import Link from 'lib/components/core/Link';
Expand All @@ -37,6 +39,9 @@ const AssessmentShowHeader = (
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [inviting, setInviting] = useState(false);
const [publishedToMarketplace, setPublishedToMarketplace] = useState(
assessment.isPublishedToMarketplace,
);
const navigate = useNavigate();

const handleDelete = (): Promise<void> => {
Expand Down Expand Up @@ -75,6 +80,9 @@ const AssessmentShowHeader = (
<PromptText>{t(translations.deletingThisAssessment)}</PromptText>
<PromptText className="italic">{assessment.title}</PromptText>
<PromptText>{t(translations.deleteAssessmentWarning)}</PromptText>
{publishedToMarketplace && (
<PromptText>{t(marketplaceTranslations.deleteWarning)}</PromptText>
)}
</DeleteButton>
)}

Expand Down Expand Up @@ -146,6 +154,16 @@ const AssessmentShowHeader = (
</Tooltip>
)}

{assessment.permissions.canPublishToMarketplace && (
<PublishToMarketplaceButton
assessment={{
...assessment,
isPublishedToMarketplace: publishedToMarketplace,
}}
onChange={setPublishedToMarketplace}
/>
)}

{assessment.actionButtonUrl && (
<Link
opensInNewTab={assessment.isKoditsuAssessmentEnabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createMockAdapter } from 'mocks/axiosMock';
import { fireEvent, render, waitFor, within } from 'test-utils';

import CourseAPI from 'api/course';

import AssessmentShowHeader from '../AssessmentShowHeader';

const mock = createMockAdapter(CourseAPI.marketplace.client);
beforeEach(() => mock.reset());

// Minimal AssessmentData: only `deleteUrl` + `title` are needed for the delete
// Prompt to render (see AssessmentShowHeader.tsx:71 / DeleteButton.tsx). All other
// action buttons stay hidden by leaving their URLs undefined, and the publish
// button stays hidden via `canPublishToMarketplace: false`.
const baseAssessment = {
id: 1,
title: 'Sample Assessment',
deleteUrl: '/courses/1/assessments/1',
status: 'open',
permissions: {
canAttempt: false,
canManage: true,
canObserve: true,
canInviteToKoditsu: false,
canPublishToMarketplace: false,
},
isPublishedToMarketplace: false,
};

// Test the conditional <PromptText> in the delete Prompt whose
// message contains this phrase, rendered only when `isPublishedToMarketplace`.
const MARKETPLACE_WARNING = /removes it from the marketplace/i;

describe('<AssessmentShowHeader />', () => {
it('warns that deletion removes the marketplace listing when the assessment is listed', async () => {
const page = render(
<AssessmentShowHeader
with={{ ...baseAssessment, isPublishedToMarketplace: true } as never}
/>,
);

// First query awaits the i18n LoadingIndicator; subsequent getBy* are sync.
fireEvent.click(await page.findByLabelText('Delete Assessment')); // opens the delete Prompt
expect(page.getByText(MARKETPLACE_WARNING)).toBeVisible();
});

it('shows no marketplace warning when the assessment is not listed', async () => {
const page = render(
<AssessmentShowHeader
with={{ ...baseAssessment, isPublishedToMarketplace: false } as never}
/>,
);

fireEvent.click(await page.findByLabelText('Delete Assessment')); // delete Prompt still opens
expect(page.queryByText(MARKETPLACE_WARNING)).not.toBeInTheDocument();
});

it('warns after the assessment is published in the same session', async () => {
mock
.onPost(`/courses/${global.courseId}/assessments/1/marketplace_listing`)
.reply(200, { published: true });

const page = render(
<AssessmentShowHeader
with={
{
...baseAssessment,
permissions: {
...baseAssessment.permissions,
canPublishToMarketplace: true,
},
} as never
}
/>,
);

fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button
const publishPrompt = await page.findByRole('dialog');
fireEvent.click(
within(publishPrompt).getByRole('button', {
name: /Publish to Marketplace/,
}),
);
await waitFor(() => expect(mock.history.post).toHaveLength(1));

// The warning reads the live state, not the initial `isPublishedToMarketplace` prop.
fireEvent.click(page.getByLabelText('Delete Assessment'));
expect(await page.findByText(MARKETPLACE_WARNING)).toBeVisible();
});
});
Loading