diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue index 6486230c77..e47f52c4e9 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/Box.vue @@ -17,6 +17,7 @@
@@ -63,6 +64,8 @@ switch (props.kind) { case 'warning': return paletteTheme.red.v_100; + case 'success': + return paletteTheme.green.v_100; case 'info': return paletteTheme.grey.v_100; default: @@ -73,6 +76,8 @@ switch (props.kind) { case 'warning': return paletteTheme.red.v_300; + case 'success': + return paletteTheme.green.v_300; case 'info': return 'transparent'; default: @@ -83,6 +88,8 @@ switch (props.kind) { case 'warning': return 'error'; + case 'success': + return 'circleCheckmark'; case 'info': return 'infoOutline'; default: @@ -91,19 +98,28 @@ }); const titleColor = computed(() => { - return props.kind === 'warning' ? paletteTheme.red.v_600 : tokensTheme.text; + if (props.kind === 'warning') return paletteTheme.red.v_600; + if (props.kind === 'success') return paletteTheme.green.v_600; + return tokensTheme.text; }); const descriptionColor = computed(() => { return props.kind === 'warning' ? paletteTheme.grey.v_800 : tokensTheme.text; }); + const iconColor = computed(() => { + if (props.kind === 'warning') return paletteTheme.red.v_600; + if (props.kind === 'success') return paletteTheme.green.v_600; + return tokensTheme.text; + }); + return { boxBackgroundColor, boxBorderColor, titleColor, descriptionColor, icon, + iconColor, }; }, props: { @@ -111,7 +127,7 @@ type: String, required: false, default: 'info', - validator: value => ['warning', 'info'].includes(value), + validator: value => ['warning', 'success', 'info'].includes(value), }, loading: { type: Boolean, diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js index 5d3d907f88..3abe807c72 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/__tests__/SubmitToCommunityLibrarySidePanel.spec.js @@ -13,12 +13,9 @@ import { communityChannelsStrings } from 'shared/strings/communityChannelsString import { CommunityLibrarySubmission } from 'shared/data/resources'; import CountryField from 'shared/views/form/CountryField.vue'; -jest.mock('../composables/usePublishedData', () => ({ - usePublishedData: jest.fn(), -})); -jest.mock('../composables/useLatestCommunityLibrarySubmission', () => ({ - useLatestCommunityLibrarySubmission: jest.fn(), -})); +jest.mock('../composables/usePublishedData'); +jest.mock('../composables/useLatestCommunityLibrarySubmission'); +jest.mock('../composables/useLicenseAudit'); jest.mock('shared/data/resources', () => ({ CommunityLibrarySubmission: { create: jest.fn(() => Promise.resolve()), diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js new file mode 100644 index 0000000000..5cc223bcfc --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLatestCommunityLibrarySubmission.js @@ -0,0 +1,19 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: ref(true), + isFinished: ref(false), + data: computed(() => null), + fetchData: jest.fn(() => Promise.resolve()), +}; + +export function useLatestCommunityLibrarySubmissionMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const useLatestCommunityLibrarySubmission = jest.fn(() => + useLatestCommunityLibrarySubmissionMock(), +); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLicenseAudit.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLicenseAudit.js new file mode 100644 index 0000000000..a8d77b7526 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/useLicenseAudit.js @@ -0,0 +1,25 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: computed(() => false), + isFinished: computed(() => true), + invalidLicenses: computed(() => []), + specialPermissions: computed(() => []), + includedLicenses: computed(() => []), + isAuditing: ref(false), + hasAuditData: computed(() => false), + auditTaskId: ref(null), + error: ref(null), + checkAndTriggerAudit: jest.fn(), + triggerAudit: jest.fn(), + fetchPublishedData: jest.fn(), +}; + +export function useLicenseAuditMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const useLicenseAudit = jest.fn(() => useLicenseAuditMock()); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js new file mode 100644 index 0000000000..8759458f73 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/__mocks__/usePublishedData.js @@ -0,0 +1,17 @@ +import { computed, ref } from 'vue'; + +const MOCK_DEFAULTS = { + isLoading: ref(true), + isFinished: ref(false), + data: computed(() => null), + fetchData: jest.fn(() => Promise.resolve()), +}; + +export function usePublishedDataMock(overrides = {}) { + return { + ...MOCK_DEFAULTS, + ...overrides, + }; +} + +export const usePublishedData = jest.fn(() => usePublishedDataMock()); diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js new file mode 100644 index 0000000000..ea4cc092d4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useLicenseAudit.js @@ -0,0 +1,127 @@ +import { computed, ref, unref, watch } from 'vue'; +import { Channel } from 'shared/data/resources'; + +export function useLicenseAudit(channelRef, channelVersionRef) { + const isAuditing = ref(false); + const auditTaskId = ref(null); + const auditError = ref(null); + const publishedData = ref(null); + + watch( + () => unref(channelRef)?.published_data, + newPublishedData => { + if (newPublishedData) { + publishedData.value = newPublishedData; + if (isAuditing.value) { + isAuditing.value = false; + auditError.value = null; + } + } + }, + { immediate: true, deep: true }, + ); + + const currentVersionData = computed(() => { + const version = unref(channelVersionRef); + if (!publishedData.value || version == null) { + return undefined; + } + return publishedData.value[version]; + }); + + const hasAuditData = computed(() => { + const versionData = currentVersionData.value; + if (!versionData) { + return false; + } + + return ( + 'community_library_invalid_licenses' in versionData && + 'community_library_special_permissions' in versionData + ); + }); + + const invalidLicenses = computed(() => { + const versionData = currentVersionData.value; + return versionData?.community_library_invalid_licenses || []; + }); + + const specialPermissions = computed(() => { + const versionData = currentVersionData.value; + return versionData?.community_library_special_permissions || []; + }); + + const includedLicenses = computed(() => { + const versionData = currentVersionData.value; + return versionData?.included_licenses || []; + }); + + const isAuditComplete = computed(() => { + return publishedData.value !== null && hasAuditData.value; + }); + + async function triggerAudit() { + if (isAuditing.value) return; + + try { + isAuditing.value = true; + auditError.value = null; + + const channelId = unref(channelRef)?.id; + if (!channelId) { + throw new Error('Channel ID is required to trigger audit'); + } + + const response = await Channel.auditLicenses(channelId); + auditTaskId.value = response.task_id; + } catch (error) { + isAuditing.value = false; + auditError.value = error; + throw error; + } + } + + async function fetchPublishedData() { + const channelId = unref(channelRef)?.id; + if (!channelId) return; + + try { + const data = await Channel.getPublishedData(channelId); + publishedData.value = data; + } catch (error) { + auditError.value = error; + throw error; + } + } + + async function checkAndTriggerAudit() { + if (!publishedData.value) { + await fetchPublishedData(); + } + + if (hasAuditData.value || isAuditing.value) { + return; + } + + await triggerAudit(); + } + + return { + isLoading: computed(() => { + if (isAuditComplete.value || auditError.value) return false; + return isAuditing.value; + }), + isFinished: computed(() => isAuditComplete.value), + isAuditing, + invalidLicenses, + specialPermissions, + includedLicenses, + hasAuditData, + auditTaskId, + error: auditError, + + checkAndTriggerAudit, + triggerAudit, + fetchPublishedData, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js new file mode 100644 index 0000000000..e8338b5709 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/composables/useSpecialPermissions.js @@ -0,0 +1,108 @@ +import { computed, ref, unref, watch } from 'vue'; +import { AuditedSpecialPermissionsLicense } from 'shared/data/resources'; + +const ITEMS_PER_PAGE = 3; + +/** + * Composable that fetches and paginates audited special-permissions licenses + * for a given set of permission IDs. + * + * @param {Array|import('vue').Ref>} permissionIds + * A list (or ref to a list) of special-permissions license IDs to fetch. + * + * @returns {{ + * permissions: import('vue').Ref>, + * currentPagePermissions: import('vue').ComputedRef>, + * isLoading: import('vue').Ref, + * error: import('vue').Ref, + * currentPage: import('vue').Ref, + * totalPages: import('vue').ComputedRef, + * nextPage: () => void, + * previousPage: () => void, + * }} + * Reactive state for the fetched, flattened permissions and pagination + * helpers used by `SpecialPermissionsList.vue`. + */ +export function useSpecialPermissions(permissionIds) { + const permissions = ref([]); + const isLoading = ref(false); + const error = ref(null); + const currentPage = ref(1); + + const totalPages = computed(() => { + return Math.ceil(permissions.value.length / ITEMS_PER_PAGE); + }); + + const currentPagePermissions = computed(() => { + const start = (currentPage.value - 1) * ITEMS_PER_PAGE; + const end = start + ITEMS_PER_PAGE; + return permissions.value.slice(start, end); + }); + + async function fetchPermissions(ids) { + if (!ids || ids.length === 0) { + permissions.value = []; + return; + } + + isLoading.value = true; + error.value = null; + + try { + const response = await AuditedSpecialPermissionsLicense.fetchCollection({ + by_ids: ids.join(','), + distributable: false, + }); + + permissions.value = response.map(permission => ({ + id: permission.id, + description: permission.description, + distributable: permission.distributable, + })); + } catch (err) { + error.value = err; + permissions.value = []; + } finally { + isLoading.value = false; + } + } + + function nextPage() { + if (currentPage.value < totalPages.value) { + currentPage.value += 1; + } + } + + function previousPage() { + if (currentPage.value > 1) { + currentPage.value -= 1; + } + } + + const resolvedPermissionIds = computed(() => { + const ids = unref(permissionIds); + if (!ids || ids.length === 0) { + return []; + } + return ids; + }); + + watch( + resolvedPermissionIds, + ids => { + fetchPermissions(ids); + }, + { immediate: true }, + ); + + return { + permissions, + currentPagePermissions, + isLoading, + error, + currentPage, + totalPages, + nextPage, + previousPage, + }; +} diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue index 7b08ee54e8..550e5c3c8b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/index.vue @@ -112,24 +112,60 @@ }) }} - +
+ +
+
+ {{ checkingChannelCompatibility$() }} +
+
+ {{ checkingChannelCompatibilitySecondary$() }} +
+
+
+ + +
tokensTheme.annotation); @@ -277,6 +322,7 @@ const isPublishing = computed(() => props.channel?.publishing === true); const currentChannelVersion = computed(() => props.channel?.version); const replacementConfirmed = ref(false); + const checkedSpecialPermissions = ref([]); const { isLoading: latestSubmissionIsLoading, @@ -368,18 +414,6 @@ ); }); - const canBeSubmitted = computed(() => { - if (isPublishing.value) return false; - const baseCondition = - canBeEdited.value && publishedDataIsFinished.value && description.value.length >= 1; - - if (needsReplacementConfirmation.value) { - return baseCondition && replacementConfirmed.value; - } - - return baseCondition; - }); - const { isLoading: publishedDataIsLoading, isFinished: publishedDataIsFinished, @@ -398,6 +432,39 @@ return channelVersion; }); + const { + isLoading: licenseAuditIsLoading, + isFinished: licenseAuditIsFinished, + invalidLicenses, + specialPermissions, + includedLicenses, + checkAndTriggerAudit: checkAndTriggerLicenseAudit, + } = useLicenseAudit(props.channel, currentChannelVersion); + + const allSpecialPermissionsChecked = ref(true); + + const hasInvalidLicenses = computed(() => { + return invalidLicenses.value && invalidLicenses.value.length > 0; + }); + + const canBeSubmitted = computed(() => { + const conditions = [ + allSpecialPermissionsChecked.value, + !isPublishing.value, + !hasInvalidLicenses.value, + licenseAuditIsFinished.value, + canBeEdited.value, + publishedDataIsFinished.value, + description.value.length >= 1, + ]; + + if (needsReplacementConfirmation.value) { + conditions.push(replacementConfirmed.value); + } + + return conditions.every(condition => condition); + }); + const latestPublishedData = computed(() => { if (!publishedData.value || !displayedVersion.value) return undefined; return publishedData.value[displayedVersion.value]; @@ -407,6 +474,7 @@ watch(isPublishing, async (newIsPublishing, oldIsPublishing) => { if (oldIsPublishing === true && newIsPublishing === false) { await fetchPublishedData(); + await checkAndTriggerLicenseAudit(); } }); @@ -415,6 +483,7 @@ if (!isPublishing.value) { await fetchPublishedData(); + await checkAndTriggerLicenseAudit(); } }); @@ -520,6 +589,11 @@ publishedDataIsFinished, detectedLanguages, detectedCategories, + licenseAuditIsLoading, + licenseAuditIsFinished, + invalidLicenses, + specialPermissions, + includedLicenses, onSubmit, // Translation functions submitToCommunityLibrary$, @@ -541,6 +615,10 @@ isPublishing, publishingMessage$, confirmReplacementText$, + checkingChannelCompatibility$, + checkingChannelCompatibilitySecondary$, + checkedSpecialPermissions, + allSpecialPermissionsChecked, }; }, props: { @@ -571,6 +649,12 @@ font-weight: 600; } + .metadata-section { + display: flex; + flex-direction: column; + gap: 4px; + } + .metadata-line { font-size: 14px; color: v-bind('annotationColor'); @@ -645,6 +729,35 @@ color: v-bind('infoTextColor'); } + .license-audit-loader { + display: flex; + flex-direction: row; + gap: 12px; + align-items: flex-start; + width: 100%; + padding: 16px 0; + } + + .audit-text-wrapper { + display: flex; + flex: 1; + flex-direction: column; + gap: 4px; + } + + .audit-text-primary { + font-size: 14px; + line-height: 140%; + color: v-bind('infoTextColor'); + } + + .audit-text-secondary { + font-size: 14px; + line-height: 140%; + color: v-bind('infoTextColor'); + opacity: 0.7; + } + .info-section { display: flex; flex-direction: column; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/CompatibleLicensesNotice.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/CompatibleLicensesNotice.vue new file mode 100644 index 0000000000..179977fac4 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/CompatibleLicensesNotice.vue @@ -0,0 +1,58 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/InvalidLicensesNotice.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/InvalidLicensesNotice.vue new file mode 100644 index 0000000000..b1ba5f3707 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/InvalidLicensesNotice.vue @@ -0,0 +1,54 @@ + + + + diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/SpecialPermissionsList.vue b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/SpecialPermissionsList.vue new file mode 100644 index 0000000000..3ad8fe8b22 --- /dev/null +++ b/contentcuration/contentcuration/frontend/channelEdit/components/sidePanels/SubmitToCommunityLibrarySidePanel/licenseCheck/SpecialPermissionsList.vue @@ -0,0 +1,198 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/data/resources.js b/contentcuration/contentcuration/frontend/shared/data/resources.js index f04944080b..6e2295c784 100644 --- a/contentcuration/contentcuration/frontend/shared/data/resources.js +++ b/contentcuration/contentcuration/frontend/shared/data/resources.js @@ -1416,6 +1416,10 @@ export const Channel = new CreateModelResource({ const response = await client.get(window.Urls.channel_published_data(id)); return response.data; }, + async auditLicenses(id) { + const response = await client.post(window.Urls.channel_audit_licenses(id)); + return response.data; + }, }); function getChannelFromChannelScope() { @@ -2411,3 +2415,11 @@ export const CommunityLibrarySubmission = new APIResource({ }); }, }); + +export const AuditedSpecialPermissionsLicense = new APIResource({ + urlName: 'audited_special_permissions_license', + async fetchCollection(params) { + const response = await client.get(this.collectionUrl(), { params }); + return response.data || []; + }, +}); diff --git a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js index 24a82e5126..ffee0a8433 100644 --- a/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js +++ b/contentcuration/contentcuration/frontend/shared/strings/communityChannelsStrings.js @@ -237,4 +237,66 @@ export const communityChannelsStrings = createTranslator('CommunityChannelsStrin message: 'Dismiss', context: 'Action in the resubmit modal to dismiss the modal', }, + checkingChannelCompatibility: { + message: 'Checking channel compatibility for submission...', + context: + 'Message shown in the "Submit to Community Library" panel while the license audit is in progress', + }, + checkingChannelCompatibilitySecondary: { + message: 'This usually takes a few seconds...', + context: + 'Secondary message shown below the main checking message to indicate the expected duration', + }, + licenseCheckPassed: { + message: 'License check passed', + context: 'Title shown when license audit passes (no invalid licenses found)', + }, + allLicensesCompatible: { + message: 'All licenses are compatible with Community Library.', + context: 'Message shown after listing compatible licenses when license check passes', + }, + incompatibleLicensesDetected: { + message: 'Incompatible license(s) detected', + context: 'Title shown when invalid licenses are detected in the channel', + }, + channelCannotBeDistributed: { + message: 'this channel cannot be distributed via Kolibri.', + context: 'Message explaining that channels with incompatible licenses cannot be distributed', + }, + fixLicensingBeforeSubmission: { + message: 'Please fix licensing before submitting a new version.', + context: 'Call to action message when incompatible licenses are detected', + }, + incompatibleLicensesDescription: { + message: + '"{licenseNames}" - this channel cannot be distributed via Kolibri. Please fix licensing before submitting a new version.', + context: + 'Description shown when incompatible licenses are detected, includes the license names and explanation', + }, + compatibleLicensesDescription: { + message: '{licenseNames} - All licenses are compatible with Community Library.', + context: + 'Description shown when all licenses are compatible, includes the license names and confirmation message', + }, + specialPermissionsDetected: { + message: 'Special Permissions license(s) detected', + context: 'Title shown when special permissions licenses are detected in the channel', + }, + confirmDistributionRights: { + message: 'Please confirm you have the right to distribute this content via Kolibri.', + context: + 'Message asking user to confirm they have distribution rights for special permissions content', + }, + previousPageAction: { + message: 'Previous', + context: 'Button text to navigate to the previous page in pagination', + }, + nextPageAction: { + message: 'Next', + context: 'Button text to navigate to the next page in pagination', + }, + pageIndicator: { + message: '{currentPage} of {totalPages}', + context: 'Page indicator showing current page and total pages (e.g., "1 of 5")', + }, }); diff --git a/contentcuration/contentcuration/frontend/shared/utils/helpers.js b/contentcuration/contentcuration/frontend/shared/utils/helpers.js index f475df5e71..f5fa5a16d8 100644 --- a/contentcuration/contentcuration/frontend/shared/utils/helpers.js +++ b/contentcuration/contentcuration/frontend/shared/utils/helpers.js @@ -7,6 +7,7 @@ import { Categories, CategoriesLookup } from 'shared/constants'; import { LicensesList } from 'shared/leUtils/Licenses'; import { ContentKindsNames } from 'shared/leUtils/ContentKinds'; import FormatPresetsMap, { FormatPresetsNames } from 'shared/leUtils/FormatPresets'; +import { constantStrings } from 'shared/mixins'; function safeParseInt(str) { const parsed = parseInt(str); @@ -303,6 +304,36 @@ export function findLicense(key, defaultValue = {}) { return license || defaultValue; } +/** + * Formats and translates license names from license IDs + * @param {Array} licenseIds - Array of license IDs + * @param {Object} options - Optional configuration + * @param {Array} options.excludes - Array of license names to exclude + * (e.g., ['Special Permissions']) + * @returns {string} Comma-separated string of translated license names + */ +export function formatLicenseNames(licenseIds, options = {}) { + if (!licenseIds || !Array.isArray(licenseIds) || licenseIds.length === 0) { + return ''; + } + + const { excludes = [] } = options; + + return licenseIds + .map(id => { + const license = findLicense(id); + const licenseName = license?.license_name; + + if (!licenseName || excludes.includes(licenseName)) { + return null; + } + + return constantStrings.$tr(licenseName); + }) + .filter(Boolean) + .join(', '); +} + /** * Creates a function that can be used to request an animation frame as well as cancel it easily. * diff --git a/contentcuration/contentcuration/urls.py b/contentcuration/contentcuration/urls.py index 7f47857f8e..5bd9deb355 100644 --- a/contentcuration/contentcuration/urls.py +++ b/contentcuration/contentcuration/urls.py @@ -34,6 +34,9 @@ import contentcuration.views.zip as zip_views from contentcuration.views import pwa from contentcuration.viewsets.assessmentitem import AssessmentItemViewSet +from contentcuration.viewsets.audited_special_permissions_license import ( + AuditedSpecialPermissionsLicenseViewSet, +) from contentcuration.viewsets.bookmark import BookmarkViewSet from contentcuration.viewsets.channel import AdminChannelViewSet from contentcuration.viewsets.channel import CatalogViewSet @@ -103,6 +106,11 @@ def get_redirect_url(self, *args, **kwargs): AdminCommunityLibrarySubmissionViewSet, basename="admin-community-library-submission", ) +router.register( + r"audited-special-permissions-license", + AuditedSpecialPermissionsLicenseViewSet, + basename="audited-special-permissions-license", +) urlpatterns = [ re_path(r"^api/", include(router.urls)), diff --git a/contentcuration/contentcuration/viewsets/audited_special_permissions_license.py b/contentcuration/contentcuration/viewsets/audited_special_permissions_license.py new file mode 100644 index 0000000000..decdaf0773 --- /dev/null +++ b/contentcuration/contentcuration/viewsets/audited_special_permissions_license.py @@ -0,0 +1,48 @@ +from django_filters.rest_framework import BooleanFilter +from django_filters.rest_framework import DjangoFilterBackend +from django_filters.rest_framework import FilterSet +from rest_framework.permissions import IsAuthenticated + +from contentcuration.models import AuditedSpecialPermissionsLicense +from contentcuration.viewsets.base import ReadOnlyValuesViewset +from contentcuration.viewsets.common import UUIDInFilter + + +class AuditedSpecialPermissionsLicenseFilter(FilterSet): + """ + Filter for AuditedSpecialPermissionsLicense viewset. + Supports filtering by IDs and distributable status. + """ + + by_ids = UUIDInFilter(field_name="id") + distributable = BooleanFilter() + + class Meta: + model = None + fields = ("by_ids", "distributable") + + def __init__(self, *args, **kwargs): + + self.Meta.model = AuditedSpecialPermissionsLicense + super().__init__(*args, **kwargs) + + +class AuditedSpecialPermissionsLicenseViewSet(ReadOnlyValuesViewset): + """ + Read-only viewset for AuditedSpecialPermissionsLicense. + Allows filtering by IDs and distributable status. + """ + + permission_classes = [IsAuthenticated] + filter_backends = [DjangoFilterBackend] + filterset_class = AuditedSpecialPermissionsLicenseFilter + + values = ( + "id", + "description", + "distributable", + ) + + def get_queryset(self): + + return AuditedSpecialPermissionsLicense.objects.all()