diff --git a/app/controllers/components/course/assessment_marketplace_component.rb b/app/controllers/components/course/assessment_marketplace_component.rb new file mode 100644 index 00000000000..a956490f46c --- /dev/null +++ b/app/controllers/components/course/assessment_marketplace_component.rb @@ -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 diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb index a54d4dae4fe..3df1e5254a5 100644 --- a/app/controllers/components/course/gradebook_component.rb +++ b/app/controllers/components/course/gradebook_component.rb @@ -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 diff --git a/app/controllers/course/assessment/marketplace/controller.rb b/app/controllers/course/assessment/marketplace/controller.rb new file mode 100644 index 00000000000..b617d93833d --- /dev/null +++ b/app/controllers/course/assessment/marketplace/controller.rb @@ -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 diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb new file mode 100644 index 00000000000..6f144d7b99f --- /dev/null +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -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 diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb new file mode 100644 index 00000000000..a05ec766cb0 --- /dev/null +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -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 diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb new file mode 100644 index 00000000000..e7a7972085c --- /dev/null +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -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 diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 7bde6a0d4bc..e489dce65d0 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -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 diff --git a/app/models/course/assessment/marketplace.rb b/app/models/course/assessment/marketplace.rb new file mode 100644 index 00000000000..235cbc69e93 --- /dev/null +++ b/app/models/course/assessment/marketplace.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Course::Assessment::Marketplace + def self.table_name_prefix + 'course_assessment_marketplace_' + end +end diff --git a/app/models/course/assessment/marketplace/adoption.rb b/app/models/course/assessment/marketplace/adoption.rb new file mode 100644 index 00000000000..dac5a616290 --- /dev/null +++ b/app/models/course/assessment/marketplace/adoption.rb @@ -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 diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb new file mode 100644 index 00000000000..0722ac64925 --- /dev/null +++ b/app/models/course/assessment/marketplace/listing.rb @@ -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 diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index d22f4b56bf4..b801cc43559 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -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 diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder new file mode 100644 index 00000000000..33ea65ec1ed --- /dev/null +++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder @@ -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 diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts new file mode 100644 index 00000000000..4feade4a090 --- /dev/null +++ b/client/app/api/course/Marketplace.ts @@ -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 { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } + + removeListing(assessmentId: number): Promise { + return this.client.delete( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } + + index(): Promise< + AxiosResponse<{ listings: MarketplaceListing[]; canAccess: boolean }> + > { + return this.client.get(this.#urlPrefix); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 355a5878c53..8087e014bc7 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -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'; @@ -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(), diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx index 3d9b1be88a9..0837787ab82 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx @@ -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'; @@ -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 => { @@ -75,6 +80,9 @@ const AssessmentShowHeader = ( {t(translations.deletingThisAssessment)} {assessment.title} {t(translations.deleteAssessmentWarning)} + {publishedToMarketplace && ( + {t(marketplaceTranslations.deleteWarning)} + )} )} @@ -146,6 +154,16 @@ const AssessmentShowHeader = ( )} + {assessment.permissions.canPublishToMarketplace && ( + + )} + {assessment.actionButtonUrl && ( 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 in the delete Prompt whose +// message contains this phrase, rendered only when `isPublishedToMarketplace`. +const MARKETPLACE_WARNING = /removes it from the marketplace/i; + +describe('', () => { + it('warns that deletion removes the marketplace listing when the assessment is listed', async () => { + const page = render( + , + ); + + // 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( + , + ); + + 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( + , + ); + + 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(); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx new file mode 100644 index 00000000000..a0a78741b2e --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx @@ -0,0 +1,27 @@ +import { Button } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; + +interface Props { + canImport: boolean; + tabId: number; +} + +const ImportAssessmentsButton = ({ + canImport, + tabId, +}: Props): JSX.Element | null => { + const { t } = useTranslation(); + if (!canImport) return null; + + return ( + + + + ); +}; + +export default ImportAssessmentsButton; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx new file mode 100644 index 00000000000..413b2b75274 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx @@ -0,0 +1,19 @@ +import { render } from 'test-utils'; + +import ImportAssessmentsButton from '../ImportAssessmentsButton'; + +it('links to the marketplace with the given tab as from_tab when the user can import', async () => { + const page = render(); + const link = await page.findByRole('link', { name: 'Import Assessments' }); + expect(link).toHaveAttribute( + 'href', + expect.stringContaining('/marketplace?from_tab=42'), + ); +}); + +it('renders nothing when the user cannot import', () => { + const page = render(); + expect( + page.queryByRole('link', { name: 'Import Assessments' }), + ).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx index fae862370ab..82a28038ba7 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx @@ -9,6 +9,7 @@ import Preload from 'lib/components/wrappers/Preload'; import { fetchAssessments } from '../../operations/assessments'; import AssessmentsTable from './AssessmentsTable'; +import ImportAssessmentsButton from './ImportAssessmentsButton'; import NewAssessmentFormButton from './NewAssessmentFormButton'; const AssessmentsIndex = (): JSX.Element => { @@ -30,17 +31,23 @@ const AssessmentsIndex = (): JSX.Element => { + <> + + + ) } title={data.display.category.title} diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 4a6e92ebf85..eddd80ac53b 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -2052,6 +2052,10 @@ const translations = defineMessages({ id: 'course.assessment.question.programming.liveFeedbackNotSupported', defaultMessage: 'Get Help is not supported for {languageName}.', }, + importAssessments: { + id: 'course.assessment.AssessmentsIndex.importAssessments', + defaultMessage: 'Import Assessments', + }, }); export default translations; diff --git a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx new file mode 100644 index 00000000000..05a2c069bf7 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Button } from '@mui/material'; +import { AssessmentData } from 'types/course/assessment/assessments'; + +import CourseAPI from 'api/course'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import toast from 'lib/hooks/toast'; + +import translations from '../translations'; + +interface Props { + assessment: Pick< + AssessmentData, + 'id' | 'isPublishedToMarketplace' | 'permissions' + >; + onChange: (published: boolean) => void; +} + +const PublishToMarketplaceButton = ({ + assessment, + onChange, +}: Props): JSX.Element | null => { + const { formatMessage: t } = useIntl(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const listed = assessment.isPublishedToMarketplace; + + if (!assessment.permissions.canPublishToMarketplace) return null; + + // `Prompt`'s primary button does not await this handler, so every rejection must be caught + // here: an uncaught one surfaces nothing to the user and becomes an unhandled rejection. + const confirm = async (): Promise => { + setSubmitting(true); + try { + if (listed) { + await CourseAPI.marketplace.removeListing(assessment.id); + toast.success(t(translations.removed)); + onChange(false); + } else { + await CourseAPI.marketplace.publishListing(assessment.id); + toast.success(t(translations.published)); + onChange(true); + } + setOpen(false); + } catch { + // Dialog stays open so the user can retry. + toast.error( + t(listed ? translations.removeFailed : translations.publishFailed), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor={listed ? 'error' : 'primary'} + primaryLabel={t(listed ? translations.remove : translations.publish)} + title={t( + listed + ? translations.removeConfirmTitle + : translations.publishConfirmTitle, + )} + > + + {t( + listed + ? translations.removeConfirmBody + : translations.publishConfirmBody, + )} + + + + ); +}; + +export default PublishToMarketplaceButton; diff --git a/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx new file mode 100644 index 00000000000..3639f497ae2 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx @@ -0,0 +1,93 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import PublishToMarketplaceButton from '../PublishToMarketplaceButton'; + +const confirmInDialog = async ( + page: ReturnType, + name: RegExp, +): Promise => { + const dialog = await page.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name })); +}; + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +const assessmentAt = ( + isPublishedToMarketplace: boolean, + canPublishToMarketplace = true, +): never => + ({ + id: 5, + isPublishedToMarketplace, + permissions: { canPublishToMarketplace }, + }) as never; + +const url = `/courses/${global.courseId}/assessments/5/marketplace_listing`; + +it('renders nothing when the user cannot publish', () => { + const page = render( + , + ); + expect(page.queryByText('Publish to Marketplace')).not.toBeInTheDocument(); + expect(page.queryByText('Remove from Marketplace')).not.toBeInTheDocument(); +}); + +it('publishes after confirming and reports published=true', async () => { + mock.onPost(url).reply(200, { published: true }); + const onChange = jest.fn(); + const page = render( + , + ); + + // findByText: test-utils wraps the tree in a translations Suspense whose fallback is a + // LoadingIndicator; the trigger button only exists after messages resolve. + fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button + await confirmInDialog(page, /Publish to Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(true); +}); + +it('removes after confirming when already listed, reports published=false', async () => { + mock.onDelete(url).reply(200); + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByText('Remove from Marketplace')); // trigger button + await confirmInDialog(page, /Remove from Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(false); +}); + +it('surfaces an error and keeps the dialog open when publishing fails', async () => { + mock.onPost(url).reply(422, { errors: ['nope'] }); + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByText('Publish to Marketplace')); + await confirmInDialog(page, /Publish to Marketplace/); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + expect(await page.findByText(/Failed to publish/i)).toBeVisible(); // error toast + expect(page.getByRole('dialog')).toBeVisible(); // still open, so the user can retry + expect(onChange).not.toHaveBeenCalled(); +}); diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts new file mode 100644 index 00000000000..8023024d8cb --- /dev/null +++ b/client/app/bundles/course/marketplace/operations.ts @@ -0,0 +1,8 @@ +import CourseAPI from 'api/course'; + +import { MarketplaceListing } from './types'; + +export const fetchListings = async (): Promise => { + const response = await CourseAPI.marketplace.index(); + return response.data.listings as MarketplaceListing[]; +}; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx new file mode 100644 index 00000000000..5cabe2e1d99 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx @@ -0,0 +1,102 @@ +import { useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { MenuItem, TextField } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; + +import translations from '../../translations'; +import { MarketplaceListing } from '../../types'; + +type SortMode = 'adoptions' | 'newest'; + +interface Props { + listings: MarketplaceListing[]; + onDuplicate: (rows: MarketplaceListing[]) => void; +} + +const MarketplaceTable = ({ listings, onDuplicate }: Props): JSX.Element => { + const { formatMessage: t } = useIntl(); + const [sortMode, setSortMode] = useState('adoptions'); + + const sorted = useMemo(() => { + const copy = [...listings]; + if (sortMode === 'newest') { + copy.sort((a, b) => + (b.firstPublishedAt ?? '').localeCompare(a.firstPublishedAt ?? ''), + ); + } else { + copy.sort((a, b) => b.adoptions - a.adoptions); + } + return copy; + }, [listings, sortMode]); + + const columns: ColumnTemplate[] = [ + { + of: 'title', + title: t(translations.colTitle), + searchable: true, + cell: (l) => l.title, + }, + { + of: 'questionCount', + title: t(translations.colQuestions), + cell: (l) => l.questionCount, + }, + { + of: 'adoptions', + title: t(translations.colAdoptions), + cell: (l) => l.adoptions, + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (l) => ( + <> + + {t(translations.preview)} + + {/* Row-level Duplicate button wired in Task 18 */} + + ), + }, + ]; + + return ( + <> + setSortMode(e.target.value as SortMode)} + select + size="small" + value={sortMode} + > + {t(translations.sortMostAdopted)} + {t(translations.sortNewest)} + + l.id.toString()} + indexing={{ rowSelectable: true }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (l, filter): boolean => + !filter || l.title.toLowerCase().includes(filter.toLowerCase()), + }, + }} + toolbar={{ + show: true, + activeToolbar: (rows) => ( + + ), + }} + /> + + ); +}; + +export default MarketplaceTable; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx new file mode 100644 index 00000000000..2d1940b9d9a --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx @@ -0,0 +1,83 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import MarketplaceIndex from '../index'; + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +// Fixture chosen so the two sort keys DISAGREE: Graph Theory is most-adopted but oldest; +// Recursion Drills is fewer adoptions but newest. This lets the sort tests prove the mode +// actually changes order rather than passing on a coincidental tie. +const LISTINGS = [ + { + id: 1, + assessmentId: 10, + title: 'Recursion Drills', + questionCount: 8, + adoptions: 5, + firstPublishedAt: '2026-06-01T00:00:00Z', + previewUrl: '/p/1', + duplicateUrl: '/d', + }, + { + id: 2, + assessmentId: 11, + title: 'Graph Theory', + questionCount: 3, + adoptions: 12, + firstPublishedAt: '2026-01-01T00:00:00Z', + previewUrl: '/p/2', + duplicateUrl: '/d', + }, +]; + +const url = `/courses/${global.courseId}/marketplace`; +const renderPage = async (page): Promise => { + await waitFor(() => expect(page.getByText('Graph Theory')).toBeVisible()); +}; + +it('renders published listings sorted by most adopted by default', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + const rows = page.getAllByRole('row'); + // Graph Theory (12 adoptions) precedes Recursion Drills (5) by default. + expect(rows[1]).toHaveTextContent('Graph Theory'); +}); + +it('re-sorts by newest when the sort mode changes', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + // Open the MUI "Sort by" select and choose Newest. + // NOTE (executor): confirm the exact idiom for driving a MUI `select`-mode TextField + // against an existing table test (e.g. mouseDown the combobox, then click the option). + fireEvent.mouseDown(page.getByLabelText('Sort by')); + fireEvent.click(page.getByRole('option', { name: 'Newest' })); + + await waitFor(() => { + const rows = page.getAllByRole('row'); + // Recursion Drills (2026-06) is newest and must now lead. + expect(rows[1]).toHaveTextContent('Recursion Drills'); + }); +}); + +it('filters rows by the title search', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + // Search field must be driven with userEvent (React 18 startTransition) — see client/CLAUDE-testing.md. + await userEvent.type(page.getByPlaceholderText('Search by title'), 'Graph'); + + await waitFor(() => + expect(page.queryByText('Recursion Drills')).not.toBeInTheDocument(), + ); + expect(page.getByText('Graph Theory')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx new file mode 100644 index 00000000000..8078f4f03f8 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; + +import Page from 'lib/components/core/layouts/Page'; +import Preload from 'lib/components/wrappers/Preload'; + +import { fetchListings } from '../../operations'; +import translations from '../../translations'; +import { MarketplaceListing } from '../../types'; + +import MarketplaceTable from './MarketplaceTable'; + +const MarketplaceIndex = (): JSX.Element => { + const { formatMessage: t } = useIntl(); + const [pending, setPending] = useState([]); + + return ( + } while={fetchListings}> + {(listings): JSX.Element => ( + + + + )} + + ); +}; + +export default MarketplaceIndex; diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts new file mode 100644 index 00000000000..a613747fbb3 --- /dev/null +++ b/client/app/bundles/course/marketplace/translations.ts @@ -0,0 +1,87 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + publish: { + id: 'course.marketplace.publish', + defaultMessage: 'Publish to Marketplace', + }, + remove: { + id: 'course.marketplace.remove', + defaultMessage: 'Remove from Marketplace', + }, + publishConfirmTitle: { + id: 'course.marketplace.publishConfirmTitle', + defaultMessage: 'Publish to Marketplace?', + }, + publishConfirmBody: { + id: 'course.marketplace.publishConfirmBody', + defaultMessage: + 'This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description.', + }, + removeConfirmTitle: { + id: 'course.marketplace.removeConfirmTitle', + defaultMessage: 'Remove from Marketplace?', + }, + removeConfirmBody: { + id: 'course.marketplace.removeConfirmBody', + defaultMessage: + 'It will no longer appear in the marketplace. Existing copies are unaffected.', + }, + published: { + id: 'course.marketplace.publishedToast', + defaultMessage: 'Published to the marketplace.', + }, + removed: { + id: 'course.marketplace.removedToast', + defaultMessage: 'Removed from the marketplace.', + }, + publishFailed: { + id: 'course.marketplace.publishFailedToast', + defaultMessage: 'Failed to publish to the marketplace. Please try again.', + }, + removeFailed: { + id: 'course.marketplace.removeFailedToast', + defaultMessage: 'Failed to remove from the marketplace. Please try again.', + }, + deleteWarning: { + id: 'course.marketplace.deleteWarning', + defaultMessage: + 'This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected.', + }, + pageTitle: { + id: 'course.marketplace.pageTitle', + defaultMessage: 'Assessment Marketplace', + }, + colTitle: { id: 'course.marketplace.colTitle', defaultMessage: 'Title' }, + colQuestions: { + id: 'course.marketplace.colQuestions', + defaultMessage: 'Questions', + }, + colAdoptions: { + id: 'course.marketplace.colAdoptions', + defaultMessage: 'Adoptions', + }, + colActions: { + id: 'course.marketplace.colActions', + defaultMessage: 'Actions', + }, + preview: { + id: 'course.marketplace.previewAction', + defaultMessage: 'Preview', + }, + searchPlaceholder: { + id: 'course.marketplace.searchPlaceholder', + defaultMessage: 'Search by title', + }, + sortLabel: { id: 'course.marketplace.sortLabel', defaultMessage: 'Sort by' }, + sortMostAdopted: { + id: 'course.marketplace.sortMostAdopted', + defaultMessage: 'Most adopted', + }, + sortNewest: { id: 'course.marketplace.sortNewest', defaultMessage: 'Newest' }, + duplicateN: { + id: 'course.marketplace.duplicateN', + defaultMessage: + '{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}', + }, +}); diff --git a/client/app/bundles/course/marketplace/types.ts b/client/app/bundles/course/marketplace/types.ts new file mode 100644 index 00000000000..58053dc1d82 --- /dev/null +++ b/client/app/bundles/course/marketplace/types.ts @@ -0,0 +1,10 @@ +export interface MarketplaceListing { + id: number; + assessmentId: number; + title: string; + questionCount: number; + adoptions: number; + firstPublishedAt: string | null; + previewUrl: string; + duplicateUrl: string; +} diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index c52ce359071..f5af35441ce 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -51,6 +51,14 @@ const translations = defineMessages({ id: 'course.componentTitles.course_announcements_component', defaultMessage: 'Announcements', }, + course_assessment_marketplace_component: { + id: 'course.componentTitles.course_assessment_marketplace_component', + defaultMessage: 'Assessment Marketplace', + }, + admin_marketplace: { + id: 'course.courses.SidebarItem.admin.marketplace', + defaultMessage: 'Assessment Marketplace', + }, course_assessments_component: { id: 'course.componentTitles.course_assessments_component', defaultMessage: 'Assessments', diff --git a/client/app/lib/constants/icons.ts b/client/app/lib/constants/icons.ts index 9c1d328e12a..b29ab3d3a9d 100644 --- a/client/app/lib/constants/icons.ts +++ b/client/app/lib/constants/icons.ts @@ -49,6 +49,8 @@ import { StairsOutlined, Star, StarOutline, + Storefront, + StorefrontOutlined, SvgIconComponent, TableChart, TableChartOutlined, @@ -84,6 +86,7 @@ export const COURSE_COMPONENT_ICONS = { statistics: { outlined: InsertChartOutlined, filled: InsertChart }, experience: { outlined: StarOutline, filled: Star }, duplication: { outlined: FileCopyOutlined, filled: FileCopy }, + marketplace: { outlined: StorefrontOutlined, filled: Storefront }, levels: { outlined: StairsOutlined, filled: Stairs }, groups: { outlined: GroupsOutlined, filled: Groups }, skills: { outlined: OfflineBoltOutlined, filled: OfflineBolt }, diff --git a/client/app/routers/course/index.tsx b/client/app/routers/course/index.tsx index 13bc90b1aca..0abdd08ba4d 100644 --- a/client/app/routers/course/index.tsx +++ b/client/app/routers/course/index.tsx @@ -10,6 +10,7 @@ import forumsRouter from './forums'; import gradebookRouter from './gradebook'; import groupsRouter from './groups'; import lessonPlanRouter from './lessonPlan'; +import marketplaceRouter from './marketplace'; import materialsRouter from './materials'; import plagiarismRouter from './plagiarism'; import scholaisticRouter from './scholaistic'; @@ -45,6 +46,7 @@ const courseRouter: Translated = (t) => ({ gradebookRouter(t), groupsRouter(t), lessonPlanRouter(t), + marketplaceRouter(t), materialsRouter(t), plagiarismRouter(t), statisticsRouter(t), diff --git a/client/app/routers/course/marketplace.tsx b/client/app/routers/course/marketplace.tsx new file mode 100644 index 00000000000..e8a42b7db09 --- /dev/null +++ b/client/app/routers/course/marketplace.tsx @@ -0,0 +1,18 @@ +import { RouteObject } from 'react-router-dom'; + +import { Translated } from 'lib/hooks/useTranslation'; + +const marketplaceRouter: Translated = () => ({ + path: 'marketplace', + children: [ + { + index: true, + lazy: async () => ({ + Component: (await import('course/marketplace/pages/MarketplaceIndex')) + .default, + }), + }, + ], +}); + +export default marketplaceRouter; diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 1fcdcb66319..f23d634b075 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -106,7 +106,10 @@ export interface AssessmentData extends AssessmentActionsData { canManage: boolean; canObserve: boolean; canInviteToKoditsu: boolean; + canPublishToMarketplace: boolean; }; + isPublishedToMarketplace: boolean; + marketplaceListingUrl: string; requirements: { title: string; satisfied?: boolean; diff --git a/client/locales/en.json b/client/locales/en.json index a655ccd405d..1913cef7b0f 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1493,6 +1493,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "Closing assessment reminder emails have been successfully dispatched." }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "Import Assessments" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "Create As Draft" }, @@ -4325,6 +4328,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "Announcements" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "Assessment Marketplace" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "Assessments" }, @@ -4583,6 +4589,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "Duplicate Data" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "Assessment Marketplace" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "Timeline Designer" }, @@ -6062,6 +6071,72 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "Experience points threshold cannot be 0" }, + "course.marketplace.publish": { + "defaultMessage": "Publish to Marketplace" + }, + "course.marketplace.remove": { + "defaultMessage": "Remove from Marketplace" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "Publish to Marketplace?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "Remove from Marketplace?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "It will no longer appear in the marketplace. Existing copies are unaffected." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "Published to the marketplace." + }, + "course.marketplace.removedToast": { + "defaultMessage": "Removed from the marketplace." + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "Failed to publish to the marketplace. Please try again." + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "Failed to remove from the marketplace. Please try again." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected." + }, + "course.marketplace.pageTitle": { + "defaultMessage": "Assessment Marketplace" + }, + "course.marketplace.colTitle": { + "defaultMessage": "Title" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "Questions" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "Adoptions" + }, + "course.marketplace.colActions": { + "defaultMessage": "Actions" + }, + "course.marketplace.previewAction": { + "defaultMessage": "Preview" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "Search by title" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "Sort by" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "Most adopted" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "Newest" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 34fa37efc36..a12807e1e38 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -1493,6 +1493,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "평가 마감 알림 이메일이 성공적으로 발송되었습니다." }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "평가 가져오기" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "드래프트로 생성" }, @@ -4307,6 +4310,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "공지 사항" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "평가 마켓플레이스" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "평가" }, @@ -4565,6 +4571,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "데이터 복제" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "평가 마켓플레이스" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "타임라인 디자이너" }, @@ -6026,6 +6035,72 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "경험치 기준은 0이 될 수 없습니다" }, + "course.marketplace.publish": { + "defaultMessage": "마켓플레이스에 게시" + }, + "course.marketplace.remove": { + "defaultMessage": "마켓플레이스에서 제거" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "마켓플레이스에 게시하시겠습니까?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "이 평가는 강좌 관리자가 찾아볼 수 있으며, 미리보기 및 복제할 수 있습니다. 이 평가의 자체 제목과 설명이 사용됩니다." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "마켓플레이스에서 제거하시겠습니까?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "더 이상 마켓플레이스에 표시되지 않습니다. 기존 복사본은 영향을 받지 않습니다." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "마켓플레이스에 게시되었습니다." + }, + "course.marketplace.removedToast": { + "defaultMessage": "마켓플레이스에서 제거되었습니다." + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "마켓플레이스에 게시하지 못했습니다. 다시 시도해 주세요." + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "마켓플레이스에서 제거하지 못했습니다. 다시 시도해 주세요." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "이 평가는 평가 마켓플레이스에 있습니다. 삭제하면 마켓플레이스에서 제거되고 채택 기록도 삭제됩니다. 다른 강좌의 기존 복사본은 영향을 받지 않습니다." + }, + "course.marketplace.pageTitle": { + "defaultMessage": "평가 마켓플레이스" + }, + "course.marketplace.colTitle": { + "defaultMessage": "제목" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "문제" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "채택" + }, + "course.marketplace.colActions": { + "defaultMessage": "작업" + }, + "course.marketplace.previewAction": { + "defaultMessage": "미리보기" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "제목으로 검색" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "정렬 기준" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "가장 많이 채택됨" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "최신순" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "{n}개 평가 복제" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 42e4ee87035..88bc1ae173c 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -1484,6 +1484,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "已成功发送结束测验的提醒邮件。" }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "导入评估" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "创建为草稿" }, @@ -4301,6 +4304,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "公告" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "评估市场" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "测验" }, @@ -4559,6 +4565,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "复制数据" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "评估市场" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "时间线设计工具" }, @@ -6020,6 +6029,72 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "经验值阈值不能为0" }, + "course.marketplace.publish": { + "defaultMessage": "发布到市场" + }, + "course.marketplace.remove": { + "defaultMessage": "从市场移除" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "发布到市场?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "课程管理员可以浏览此评估,并可预览和复制它。它会使用此评估自身的标题和描述。" + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "从市场移除?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "它将不再显示在市场中。现有副本不受影响。" + }, + "course.marketplace.publishedToast": { + "defaultMessage": "已发布到市场。" + }, + "course.marketplace.removedToast": { + "defaultMessage": "已从市场移除。" + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "发布到市场失败,请重试。" + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "从市场移除失败,请重试。" + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "此评估位于评估市场中。删除它会将其从市场移除,并删除其采用历史记录。其他课程中的现有副本不受影响。" + }, + "course.marketplace.pageTitle": { + "defaultMessage": "评估市场" + }, + "course.marketplace.colTitle": { + "defaultMessage": "标题" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "问题" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "采用次数" + }, + "course.marketplace.colActions": { + "defaultMessage": "操作" + }, + "course.marketplace.previewAction": { + "defaultMessage": "预览" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "按标题搜索" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "排序方式" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "采用最多" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "最新" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "复制 {n} 个评估" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, diff --git a/config/routes.rb b/config/routes.rb index a65da4a24dd..e22f59bff08 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -287,6 +287,8 @@ resources :mock_answers, on: :member, only: [:index, :create, :update, :destroy] end + resource :marketplace_listing, only: [:create, :destroy] + namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do post :generate, on: :collection @@ -615,6 +617,13 @@ get 'learn_settings', to: 'stories#learn_settings' get 'mission_control', to: 'stories#mission_control' end + + scope module: 'assessment/marketplace' do + get 'marketplace' => 'listings#index', as: :marketplace + resources :listings, only: [:show], path: 'marketplace/listings' do + post 'duplicate', on: :collection + end + end end end diff --git a/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb new file mode 100644 index 00000000000..e1b094eb9ad --- /dev/null +++ b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb @@ -0,0 +1,30 @@ +class CreateCourseAssessmentMarketplaceListings < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_listings do |t| + t.references :assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_course_assessment_marketplace_listings_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_listings_assessment_id', + unique: true } + t.boolean :published, null: false, default: false + t.datetime :first_published_at + t.datetime :last_published_at + t.references :publisher, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_publisher_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_publisher_id' } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_creator_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_updater_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_listings, :published, + name: 'index_course_assessment_marketplace_listings_on_published' + end +end diff --git a/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb new file mode 100644 index 00000000000..1f7eee7c3d6 --- /dev/null +++ b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb @@ -0,0 +1,33 @@ +class CreateCourseAssessmentMarketplaceAdoptions < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_adoptions do |t| + t.references :listing, null: false, + foreign_key: { to_table: :course_assessment_marketplace_listings, + name: 'fk_course_assessment_marketplace_adoptions_listing_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_adoptions_listing_id' } + t.references :destination_course, null: false, + foreign_key: { to_table: :courses, + name: 'fk_cama_destination_course_id', + on_delete: :cascade }, + index: { name: 'fk__cama_destination_course_id' } + t.references :duplicated_assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_cama_duplicated_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__cama_duplicated_assessment_id', + unique: true } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_creator_id' }, + index: { name: 'fk__cama_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_updater_id' }, + index: { name: 'fk__cama_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_adoptions, [:listing_id, :destination_course_id], + name: 'index_cama_on_listing_id_and_destination_course_id' + end +end diff --git a/db/schema.rb b/db/schema.rb index 9af6d94e066..00735f06770 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_01_100000) do +ActiveRecord::Schema[7.2].define(version: 2026_07_07_000002) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -270,6 +270,39 @@ t.index ["question_id"], name: "index_course_assessment_live_feedbacks_on_question_id" end + create_table "course_assessment_marketplace_adoptions", force: :cascade do |t| + t.bigint "listing_id", null: false + t.bigint "destination_course_id", null: false + t.bigint "duplicated_assessment_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "fk__cama_creator_id" + t.index ["destination_course_id"], name: "fk__cama_destination_course_id" + t.index ["duplicated_assessment_id"], name: "fk__cama_duplicated_assessment_id", unique: true + t.index ["listing_id", "destination_course_id"], name: "index_cama_on_listing_id_and_destination_course_id" + t.index ["listing_id"], name: "fk__course_assessment_marketplace_adoptions_listing_id" + t.index ["updater_id"], name: "fk__cama_updater_id" + end + + create_table "course_assessment_marketplace_listings", force: :cascade do |t| + t.bigint "assessment_id", null: false + t.boolean "published", default: false, null: false + t.datetime "first_published_at" + t.datetime "last_published_at" + t.bigint "publisher_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assessment_id"], name: "fk__course_assessment_marketplace_listings_assessment_id", unique: true + t.index ["creator_id"], name: "fk__course_assessment_marketplace_listings_creator_id" + t.index ["published"], name: "index_course_assessment_marketplace_listings_on_published" + t.index ["publisher_id"], name: "fk__course_assessment_marketplace_listings_publisher_id" + t.index ["updater_id"], name: "fk__course_assessment_marketplace_listings_updater_id" + end + create_table "course_assessment_plagiarism_checks", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false @@ -1979,6 +2012,15 @@ add_foreign_key "course_assessment_live_feedbacks", "course_assessment_questions", column: "question_id" add_foreign_key "course_assessment_live_feedbacks", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_live_feedbacks", "users", column: "creator_id" + add_foreign_key "course_assessment_marketplace_adoptions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_course_assessment_marketplace_adoptions_listing_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "course_assessments", column: "duplicated_assessment_id", name: "fk_cama_duplicated_assessment_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "courses", column: "destination_course_id", name: "fk_cama_destination_course_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "creator_id", name: "fk_course_assessment_marketplace_adoptions_creator_id" + add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" + add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" + add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" + add_foreign_key "course_assessment_marketplace_listings", "users", column: "updater_id", name: "fk_course_assessment_marketplace_listings_updater_id" add_foreign_key "course_assessment_plagiarism_checks", "course_assessments", column: "assessment_id", name: "fk_course_assessment_plagiarism_checks_assessment_id" add_foreign_key "course_assessment_plagiarism_checks", "jobs", name: "fk_course_assessment_plagiarism_checks_job_id", on_delete: :nullify add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_question_bundles", column: "bundle_id" diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb new file mode 100644 index 00000000000..8c0b966a9ad --- /dev/null +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::AssessmentsController, type: :controller do + render_views + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + describe 'GET #show — marketplace fields' do + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'grants the publish permission and reports not-yet-published' do + get :show, as: :json, params: { course_id: course, id: assessment } + body = JSON.parse(response.body) + expect(body['permissions']).to include('canPublishToMarketplace' => true) + expect(body).to include('isPublishedToMarketplace' => false) + expect(body['marketplaceListingUrl']).to be_present + end + + it 'reports isPublishedToMarketplace true once a published listing exists' do + create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)).to include('isPublishedToMarketplace' => true) + end + end + + context 'as a course manager (non-admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + + it 'withholds the publish permission' do + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)['permissions']).to include('canPublishToMarketplace' => false) + end + end + end + end +end diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb new file mode 100644 index 00000000000..4caf08b581d --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ListingsController, type: :controller do + render_views # index.json.jbuilder output is asserted below — controller specs don't render views otherwise + + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + + before { controller_sign_in(controller, manager.user) } + + describe 'GET #index' do + let!(:published) { create(:course_assessment_marketplace_listing, published: true) } + let!(:unpublished) { create(:course_assessment_marketplace_listing, published: false) } + + it 'returns only published listings' do + get :index, params: { course_id: course, format: :json } + ids = response.parsed_body['listings'].map { |l| l['id'] } + expect(ids).to include(published.id) + expect(ids).not_to include(unpublished.id) + end + + it 'includes title, question count and adoptions, and canAccess' do + get :index, params: { course_id: course, format: :json } + expect(response.parsed_body['canAccess']).to be(true) + row = response.parsed_body['listings'].find { |l| l['id'] == published.id } + expect(row).to include('title', 'questionCount', 'adoptions', 'previewUrl', 'duplicateUrl') + end + + it 'reports the actual question count for a listing (not the 0 fallback)' do + assessment_with_questions = create(:assessment, :with_mcq_question, question_count: 3, course: course) + listing = create(:course_assessment_marketplace_listing, published: true, assessment: assessment_with_questions) + get :index, params: { course_id: course, format: :json } + row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } + expect(row['questionCount']).to eq(3) + end + + context 'as a student' do + let(:student) { create(:course_student, course: course).user } + before { controller_sign_in(controller, student) } + it 'is forbidden' do + expect do + get :index, params: { course_id: course, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + end + end + + # Cross-instance: a listing published in another instance is visible. + describe 'cross-instance visibility' do + let(:other_instance) { create(:instance) } + let(:home_instance) { create(:instance) } + + it 'lists listings from other instances' do + foreign = ActsAsTenant.with_tenant(other_instance) do + create(:course_assessment_marketplace_listing, published: true) + end + ActsAsTenant.with_tenant(home_instance) do + course = create(:course) + manager = create(:course_manager, course: course) + controller_sign_in(controller, manager.user) + # Point the request at the home instance's host so `deduce_tenant` resolves it (this + # describe is outside `with_tenant`, which would otherwise set the host header for us). + @request.headers['host'] = home_instance.host + get :index, params: { course_id: course, format: :json } + ids = response.parsed_body['listings'].map { |l| l['id'] } + expect(ids).to include(foreign.id) + end + end + end +end diff --git a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb new file mode 100644 index 00000000000..2f261b11b01 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::MarketplaceListingsController, type: :controller do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + subject { post :create, params: { course_id: course, assessment_id: assessment, format: :json } } + + it 'creates a published listing' do + expect { subject }.to change { Course::Assessment::Marketplace::Listing.count }.by(1) + listing = assessment.reload.marketplace_listing + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_present + expect(listing.last_published_at).to be_present + expect(listing.publisher).to eq(admin) + end + + context 'when the assessment was previously published then removed (re-publish)' do + let!(:listing) do + create(:course_assessment_marketplace_listing, assessment: assessment, published: false, + first_published_at: 3.days.ago, last_published_at: 3.days.ago) + end + + it 'reuses the existing row, preserves first_published_at, bumps last_published_at' do + original_first = listing.first_published_at + expect { subject }.not_to(change { Course::Assessment::Marketplace::Listing.count }) + listing.reload + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_within(1.second).of(original_first) # NOT overwritten + expect(listing.last_published_at).to be > original_first # bumped to now + end + + it 'stamps the re-publishing admin as the publisher' do + expect(listing.publisher).not_to eq(admin) # factory publisher: the course creator + subject + expect(listing.reload.publisher).to eq(admin) # moves with last_published_at + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + end + + describe 'DELETE #destroy' do + let!(:listing) { create(:course_assessment_marketplace_listing, assessment: assessment, published: true) } + + it 'soft-removes: keeps the row, sets published false' do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + expect(listing.reload.published).to be(false) + expect(Course::Assessment::Marketplace::Listing.exists?(listing.id)).to be(true) + end + + context 'when the assessment has no marketplace listing' do + let(:unlisted_assessment) { create(:assessment, course: course) } + + it 'responds unprocessable' do + delete :destroy, params: { course_id: course, assessment_id: unlisted_assessment, format: :json } + expect(response).to have_http_status(:unprocessable_content) + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it 'is forbidden and leaves the listing published' do + expect do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + expect(listing.reload.published).to be(true) + end + end + end + end +end diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb new file mode 100644 index 00000000000..0ecb37ec07d --- /dev/null +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::AssessmentMarketplaceComponent do + controller(Course::Controller) {} # rubocop:disable Lint/EmptyBlock + + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + + subject do + controller.instance_variable_set(:@course, course) + described_class.new(controller) + end + + context 'when the user can access the marketplace (course manager)' do + let(:user) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, user) } + + it 'exposes an admin sidebar item pointing at the marketplace' do + item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace } + expect(item).to be_present + expect(item[:type]).to eq(:admin) + expect(item[:path]).to eq(course_marketplace_path(course)) + end + end + + context 'when the user cannot access the marketplace (course student)' do + let(:user) { create(:course_student, course: course).user } + before { controller_sign_in(controller, user) } + + it 'exposes no sidebar item' do + expect(subject.sidebar_items).to be_empty + end + end + end +end diff --git a/spec/factories/course_assessment_marketplace_adoptions.rb b/spec/factories/course_assessment_marketplace_adoptions.rb new file mode 100644 index 00000000000..912723e5b67 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_adoptions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_adoption, + class: Course::Assessment::Marketplace::Adoption do + listing { association :course_assessment_marketplace_listing } + destination_course { association :course } + duplicated_assessment { association :assessment, course: destination_course } + end +end diff --git a/spec/factories/course_assessment_marketplace_listings.rb b/spec/factories/course_assessment_marketplace_listings.rb new file mode 100644 index 00000000000..6ea0a819ffe --- /dev/null +++ b/spec/factories/course_assessment_marketplace_listings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_listing, + class: Course::Assessment::Marketplace::Listing do + transient do + course { nil } + end + assessment { association :assessment, course: course || create(:course) } + publisher { assessment.course.creator } + published { true } + first_published_at { Time.zone.now } + last_published_at { Time.zone.now } + end +end diff --git a/spec/models/course/assessment/marketplace/adoption_spec.rb b/spec/models/course/assessment/marketplace/adoption_spec.rb new file mode 100644 index 00000000000..7b0e9c38488 --- /dev/null +++ b/spec/models/course/assessment/marketplace/adoption_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Adoption, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:listing).class_name('Course::Assessment::Marketplace::Listing') } + it { is_expected.to belong_to(:destination_course).class_name('Course') } + it { is_expected.to belong_to(:duplicated_assessment).class_name('Course::Assessment') } + + it 'validates uniqueness of duplicated_assessment_id' do + existing = create(:course_assessment_marketplace_adoption) + dup = build(:course_assessment_marketplace_adoption, + duplicated_assessment: existing.duplicated_assessment) + expect(dup).not_to be_valid + end + + it 'is destroyed when its duplicated assessment is destroyed (DB cascade)' do + adoption = create(:course_assessment_marketplace_adoption) + adoption.duplicated_assessment.destroy + expect(described_class.exists?(adoption.id)).to be(false) + end + end +end diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb new file mode 100644 index 00000000000..7eeb9a26f3c --- /dev/null +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Listing, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:assessment).class_name('Course::Assessment') } + it { is_expected.to belong_to(:publisher).class_name('User') } + it do + is_expected.to have_many(:adoptions). + class_name('Course::Assessment::Marketplace::Adoption').dependent(:destroy) + end + + describe 'validations' do + subject { build(:course_assessment_marketplace_listing) } + + it { is_expected.to validate_presence_of(:publisher) } + + it 'validates uniqueness of assessment_id' do + existing = create(:course_assessment_marketplace_listing) + dup = build(:course_assessment_marketplace_listing, assessment: existing.assessment) + expect(dup).not_to be_valid + end + end + + describe '.published' do + it 'includes published listings and excludes unpublished ones' do + published = create(:course_assessment_marketplace_listing, published: true) + unpublished = create(:course_assessment_marketplace_listing, published: false) + expect(described_class.published).to include(published) + expect(described_class.published).not_to include(unpublished) + end + end + + describe '#adoption_count' do + subject { create(:course_assessment_marketplace_listing) } + + it 'counts distinct destination courses' do + course_a = create(:course) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: create(:course)) + expect(subject.adoption_count).to eq(2) + end + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb new file mode 100644 index 00000000000..1dabaf126c8 --- /dev/null +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:listing) { create(:course_assessment_marketplace_listing, published: true) } + let(:published_assessment) { listing.assessment } + + subject { Ability.new(user, course, course_user) } + + context 'when the user is a system administrator' do + let(:user) { create(:administrator) } + let(:course_user) { nil } + it { is_expected.to be_able_to(:publish_to_marketplace, build(:assessment)) } + end + + context 'when the user is a course manager' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + it { is_expected.to be_able_to(:access_marketplace, course) } + it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } + it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + + it 'cannot duplicate/preview an unpublished listing' do + unpublished = create(:course_assessment_marketplace_listing, published: false).assessment + expect(subject).not_to be_able_to(:duplicate_from_marketplace, unpublished) + expect(subject).not_to be_able_to(:preview_in_marketplace, unpublished) + end + end + + context 'when the user is a course student' do + let(:course_user) { create(:course_student, course: course) } + let(:user) { course_user.user } + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + end +end