From 9a84d0e5a7052ea905ad15319b118f02023cd971 Mon Sep 17 00:00:00 2001 From: mkm17 Date: Thu, 3 Oct 2024 18:39:34 +0200 Subject: [PATCH] Adds command `spp model remove`. Closes #6118 --- docs/docs/cmd/spp/model/model-remove.mdx | 53 ++++ docs/src/config/sidebars.ts | 9 + src/m365/spp/commands.ts | 3 +- .../spp/commands/model/model-remove.spec.ts | 248 ++++++++++++++++++ src/m365/spp/commands/model/model-remove.ts | 131 +++++++++ src/utils/spp.spec.ts | 48 ++++ src/utils/spp.ts | 29 ++ 7 files changed, 520 insertions(+), 1 deletion(-) create mode 100644 docs/docs/cmd/spp/model/model-remove.mdx create mode 100644 src/m365/spp/commands/model/model-remove.spec.ts create mode 100644 src/m365/spp/commands/model/model-remove.ts create mode 100644 src/utils/spp.spec.ts create mode 100644 src/utils/spp.ts diff --git a/docs/docs/cmd/spp/model/model-remove.mdx b/docs/docs/cmd/spp/model/model-remove.mdx new file mode 100644 index 00000000000..a22d94e6f3d --- /dev/null +++ b/docs/docs/cmd/spp/model/model-remove.mdx @@ -0,0 +1,53 @@ +import Global from '/docs/cmd/_global.mdx'; +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# spp model remove + +Deletes a document understanding model + +## Usage + +```sh +m365 spp model remove [options] +``` + +## Options + +```md definition-list +`-u, --siteUrl ` +: The URL of the content center site. + +`-i, --id [id]` +: The unique ID of the model to delete. Specify either `id` or `title` but not both. + +`-t, --title [title]` +: The display name (case-sensitive) of the model to remove. Specify either `id` or `title` but not both. + +`-f --force` +: Don't prompt for confirming removing the model. +``` + + + +## Remarks + +Note that this model will be removed from all libraries before it can be deleted. + +## Examples + +Delete a SharePoint Premium document understanding model using the model’s UniqueId. + +```sh +m365 spp model remove --siteUrl "https://contoso.sharepoint.com/sites/ContentCenter" --id "7645e69d-21fb-4a24-a17a-9bdfa7cb63dc" +``` + +Delete a SharePoint Premium document understanding model using the model’s title. + +```sh +m365 spp model remove --siteUrl "https://contoso.sharepoint.com/sites/ContentCenter" --title "climicrosoft365Model.classifier" +``` + +## Response + +The command won't return a response on success. diff --git a/docs/src/config/sidebars.ts b/docs/src/config/sidebars.ts index 0370e28df09..46c42f1bb81 100644 --- a/docs/src/config/sidebars.ts +++ b/docs/src/config/sidebars.ts @@ -3952,6 +3952,15 @@ const sidebars: SidebarsConfig = { id: 'cmd/spp/contentcenter/contentcenter-list' } ] + }, + { + model: [ + { + type: 'doc', + label: 'model remove', + id: 'cmd/spp/model/model-remove' + } + ] } ] }, diff --git a/src/m365/spp/commands.ts b/src/m365/spp/commands.ts index d3309979bb6..d160edf388e 100644 --- a/src/m365/spp/commands.ts +++ b/src/m365/spp/commands.ts @@ -1,5 +1,6 @@ const prefix: string = 'spp'; export default { - CONTENTCENTER_LIST: `${prefix} contentcenter list` + CONTENTCENTER_LIST: `${prefix} contentcenter list`, + MODEL_REMOVE: `${prefix} model remove` }; \ No newline at end of file diff --git a/src/m365/spp/commands/model/model-remove.spec.ts b/src/m365/spp/commands/model/model-remove.spec.ts new file mode 100644 index 00000000000..217b027fd6f --- /dev/null +++ b/src/m365/spp/commands/model/model-remove.spec.ts @@ -0,0 +1,248 @@ +import assert from 'assert'; +import sinon from 'sinon'; +import auth from '../../../../Auth.js'; +import { cli } from '../../../../cli/cli.js'; +import { CommandInfo } from '../../../../cli/CommandInfo.js'; +import { Logger } from '../../../../cli/Logger.js'; +import { CommandError } from '../../../../Command.js'; +import request from '../../../../request.js'; +import { telemetry } from '../../../../telemetry.js'; +import { pid } from '../../../../utils/pid.js'; +import { session } from '../../../../utils/session.js'; +import { sinonUtil } from '../../../../utils/sinonUtil.js'; +import commands from '../../commands.js'; +import command from './model-remove.js'; + +describe(commands.MODEL_REMOVE, () => { + let log: string[]; + let logger: Logger; + let commandInfo: CommandInfo; + + before(() => { + sinon.stub(auth, 'restoreAuth').resolves(); + sinon.stub(telemetry, 'trackEvent').returns(); + sinon.stub(pid, 'getProcessName').returns(''); + sinon.stub(session, 'getId').returns(''); + auth.connection.active = true; + commandInfo = cli.getCommandInfo(command); + }); + + beforeEach(() => { + log = []; + logger = { + log: async (msg: string) => { + log.push(msg); + }, + logRaw: async (msg: string) => { + log.push(msg); + }, + logToStderr: async (msg: string) => { + log.push(msg); + } + }; + }); + + afterEach(() => { + sinonUtil.restore([ + request.get, + request.delete, + cli.promptForConfirmation + ]); + }); + + after(() => { + sinon.restore(); + auth.connection.active = false; + }); + + it('has correct name', () => { + assert.strictEqual(command.name, commands.MODEL_REMOVE); + }); + + it('has a description', () => { + assert.notStrictEqual(command.description, null); + }); + + it('passes validation when required parameters are valid with id', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with title', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', title: 'ModelName' } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('passes validation when required parameters are valid with id and force', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }, commandInfo); + assert.strictEqual(actual, true); + }); + + it('fails validation when siteUrl is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'invalidUrl', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('fails validation when id is not valid', async () => { + const actual = await command.validate({ options: { siteUrl: 'https://contoso.sharepoint.com/sites/sales', id: 'foo' } }, commandInfo); + assert.notStrictEqual(actual, true); + }); + + it('correctly handles site is not Content Site', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'SITEPAGEPUBLISHING#0' + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }), + new CommandError('https://contoso.sharepoint.com/sites/portal is not a content site.')); + }); + + + it('correctly handles an access denied error', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/web?$select=WebTemplateConfiguration`) { + throw { + error: { + "odata.error": { + message: { + lang: "en-US", + value: "Attempted to perform an unauthorized operation." + } + } + } + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(command.action(logger, { options: { verbose: true, siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f', force: true } }), + new CommandError('Attempted to perform an unauthorized operation.')); + }); + + + it('deletes model by id', async () => { + const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(true); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'CONTENTCTR#0' + }; + } + throw 'Invalid request'; + }); + + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + assert.strictEqual(stubDelete.lastCall.args[0].url, `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`); + assert(confirmationStub.calledOnce); + }); + + it('does not delete model when confirmation is not accepted', async () => { + const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(false); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'CONTENTCTR#0' + }; + } + throw 'Invalid request'; + }); + + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + assert(stubDelete.notCalled); + assert(confirmationStub.calledOnce); + }); + + it('deletes model by id with force', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'CONTENTCTR#0' + }; + } + throw 'Invalid request'; + }); + + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('164720c8-35ee-4157-ba26-db6726264f9d')`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', id: '164720c8-35ee-4157-ba26-db6726264f9d', force: true } }); + assert.strictEqual(stubDelete.lastCall.args[0].url, `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('164720c8-35ee-4157-ba26-db6726264f9d')`); + }); + + it('deletes model when the the site URL has trailing slash', async () => { + const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(true); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'CONTENTCTR#0' + }; + } + throw 'Invalid request'; + }); + + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal/', id: '9b1b1e42-794b-4c71-93ac-5ed92488b67f' } }); + assert.strictEqual(stubDelete.lastCall.args[0].url, `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbyuniqueid('9b1b1e42-794b-4c71-93ac-5ed92488b67f')`); + assert(confirmationStub.calledOnce); + }); + + it('deletes model by title', async () => { + const confirmationStub = sinon.stub(cli, 'promptForConfirmation').resolves(true); + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'CONTENTCTR#0' + }; + } + + throw 'Invalid request'; + }); + + const stubDelete = sinon.stub(request, 'delete').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbytitle('ModelName')`) { + return; + } + + throw 'Invalid request'; + }); + + await command.action(logger, { options: { siteUrl: 'https://contoso.sharepoint.com/sites/portal', title: 'ModelName' } }); + assert.strictEqual(stubDelete.lastCall.args[0].url, `https://contoso.sharepoint.com/sites/portal/_api/machinelearning/models/getbytitle('ModelName')`); + assert(confirmationStub.calledOnce); + }); +}); \ No newline at end of file diff --git a/src/m365/spp/commands/model/model-remove.ts b/src/m365/spp/commands/model/model-remove.ts new file mode 100644 index 00000000000..d384831d51d --- /dev/null +++ b/src/m365/spp/commands/model/model-remove.ts @@ -0,0 +1,131 @@ +import { cli } from '../../../../cli/cli.js'; +import { Logger } from '../../../../cli/Logger.js'; +import GlobalOptions from '../../../../GlobalOptions.js'; +import request, { CliRequestOptions } from '../../../../request.js'; +import { formatting } from '../../../../utils/formatting.js'; +import { spp } from '../../../../utils/spp.js'; +import { urlUtil } from '../../../../utils/urlUtil.js'; +import { validation } from '../../../../utils/validation.js'; +import SpoCommand from '../../../base/SpoCommand.js'; +import commands from '../../commands.js'; + +interface CommandArgs { + options: Options; +} + +interface Options extends GlobalOptions { + siteUrl: string; + id?: string; + title?: string; + force?: boolean; +} + +class SppModelRemoveCommand extends SpoCommand { + public get name(): string { + return commands.MODEL_REMOVE; + } + + public get description(): string { + return 'Deletes a document understanding model'; + } + + constructor() { + super(); + + this.#initTelemetry(); + this.#initOptions(); + this.#initValidators(); + this.#initOptionSets(); + this.#initTypes(); + } + + #initTelemetry(): void { + this.telemetry.push((args: CommandArgs) => { + Object.assign(this.telemetryProperties, { + id: typeof args.options.id !== 'undefined', + title: typeof args.options.title !== 'undefined', + force: !!args.options.force + }); + }); + } + + #initOptions(): void { + this.options.unshift( + { + option: '-u, --siteUrl ' + }, + { + option: '-i, --id [id]' + }, + { + option: '-t, --title [title]' + }, + { + option: '-f --force' + } + ); + } + + #initValidators(): void { + this.validators.push( + async (args: CommandArgs) => { + if (args.options.id && !validation.isValidGuid(args.options.id)) { + return `${args.options.id} is not a valid GUID`; + } + + return validation.isValidSharePointUrl(args.options.siteUrl); + } + ); + } + + #initOptionSets(): void { + this.optionSets.push({ options: ['id', 'title'] }); + } + + #initTypes(): void { + this.types.string.push('siteUrl', 'id', 'title'); + this.types.boolean.push('force'); + } + + public async commandAction(logger: Logger, args: CommandArgs): Promise { + try { + if (!args.options.force) { + const confirmationResult = await cli.promptForConfirmation({ message: `Are you sure you want to remove the model '${args.options.id || args.options.title}'?` }); + + if (!confirmationResult) { + return; + } + } + + if (this.verbose) { + await logger.log(`Removing model from ${args.options.siteUrl}...`); + } + + const siteUrl = urlUtil.removeTrailingSlashes(args.options.siteUrl); + await spp.assertSiteIsContentCenter(siteUrl); + + const requestOptions: CliRequestOptions = { + url: this.getCorrectRequestUrl(siteUrl, args), + headers: { + accept: 'application/json;odata=nometadata', + 'if-match': '*' + } + }; + + await request.delete(requestOptions); + } + catch (err: any) { + this.handleRejectedODataJsonPromise(err); + } + } + + private getCorrectRequestUrl(siteUrl: string, args: CommandArgs): string { + if (args.options.id) { + return `${siteUrl}/_api/machinelearning/models/getbyuniqueid('${args.options.id}')`; + } + + return `${siteUrl}/_api/machinelearning/models/getbytitle('${formatting.encodeQueryParameter(args.options.title!)}')`; + } +} + +export default new SppModelRemoveCommand(); \ No newline at end of file diff --git a/src/utils/spp.spec.ts b/src/utils/spp.spec.ts new file mode 100644 index 00000000000..cbd5e924d40 --- /dev/null +++ b/src/utils/spp.spec.ts @@ -0,0 +1,48 @@ + +import assert from 'assert'; +import sinon from 'sinon'; +import { spp } from './spp.js'; +import { sinonUtil } from './sinonUtil.js'; +import request from '../request.js'; + +describe('utils/spp', () => { + const siteUrl = 'https://contoso.sharepoint.com'; + afterEach(() => { + sinonUtil.restore([ + request.get + ]); + }); + + after(() => { + sinon.restore(); + }); + + it('calls api correctly and throw an error when site is not a content center using assertSiteIsContentCenter', async () => { + sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'SITEPAGEPUBLISHING#0' + }; + } + + throw 'Invalid request'; + }); + + await assert.rejects(spp.assertSiteIsContentCenter(siteUrl), Error('https://contoso.sharepoint.com is not a content site.')); + }); + + it('calls api correctly and does not throw an error when site is a content center using assertSiteIsContentCenter', async () => { + const stubGet = sinon.stub(request, 'get').callsFake(async (opts) => { + if (opts.url === `https://contoso.sharepoint.com/_api/web?$select=WebTemplateConfiguration`) { + return { + WebTemplateConfiguration: 'CONTENTCTR#0' + }; + } + + throw 'Invalid request'; + }); + + await spp.assertSiteIsContentCenter(siteUrl); + assert(stubGet.calledOnce); + }); +}); \ No newline at end of file diff --git a/src/utils/spp.ts b/src/utils/spp.ts new file mode 100644 index 00000000000..0b6dff3454b --- /dev/null +++ b/src/utils/spp.ts @@ -0,0 +1,29 @@ +import request, { CliRequestOptions } from '../request.js'; + +export interface SppModel { + UniqueId: string; + Publications?: any[]; +} + +export const spp = { + /** + * Asserts whether the specified site is a content center + * @param siteUrl The URL of the site to check + * @throws error when site is not a content center. + */ + async assertSiteIsContentCenter(siteUrl: string): Promise { + const requestOptions: CliRequestOptions = { + url: `${siteUrl}/_api/web?$select=WebTemplateConfiguration`, + headers: { + accept: 'application/json;odata=nometadata' + }, + responseType: 'json' + }; + + const response = await request.get<{ WebTemplateConfiguration: string }>(requestOptions); + + if (response.WebTemplateConfiguration !== 'CONTENTCTR#0') { + throw Error(`${siteUrl} is not a content site.`); + } + } +}; \ No newline at end of file