Browse Source

Merge pull request #1536 from Lezek123/cli-curation

CLI - content curation
Mokhtar Naamani 4 years ago
parent
commit
4822592bbc

+ 45 - 8
cli/src/base/ContentDirectoryCommandBase.ts

@@ -1,28 +1,65 @@
 import ExitCodes from '../ExitCodes'
-import AccountsCommandBase from './AccountsCommandBase'
-import { WorkingGroups, NamedKeyringPair } from '../Types'
+import { WorkingGroups } from '../Types'
 import { ReferenceProperty } from 'cd-schemas/types/extrinsics/AddClassSchema'
 import { FlattenRelations } from 'cd-schemas/types/utility'
 import { BOOL_PROMPT_OPTIONS } from '../helpers/prompting'
-import { Class, ClassId, CuratorGroup, CuratorGroupId, Entity, EntityId } from '@joystream/types/content-directory'
+import {
+  Class,
+  ClassId,
+  CuratorGroup,
+  CuratorGroupId,
+  Entity,
+  EntityId,
+  Actor,
+} from '@joystream/types/content-directory'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
 import { Codec } from '@polkadot/types/types'
 import _ from 'lodash'
+import { RolesCommandBase } from './WorkingGroupsCommandBase'
+import { createType } from '@joystream/types'
 import chalk from 'chalk'
 
 /**
  * Abstract base class for commands related to content directory
  */
-export default abstract class ContentDirectoryCommandBase extends AccountsCommandBase {
+export default abstract class ContentDirectoryCommandBase extends RolesCommandBase {
+  group = WorkingGroups.Curators // override group for RolesCommandBase
+
   // Use when lead access is required in given command
   async requireLead(): Promise<void> {
-    const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
-    const lead = await this.getApi().groupLead(WorkingGroups.Curators)
+    await this.getRequiredLead()
+  }
+
+  async getCuratorContext(classNames: string[] = []): Promise<Actor> {
+    const curator = await this.getRequiredWorker()
+    const classes = await Promise.all(classNames.map(async (cName) => (await this.classEntryByNameOrId(cName))[1]))
+    const classMaintainers = classes.map(({ class_permissions: permissions }) => permissions.maintainers.toArray())
 
-    if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-      this.error('Content Working Group Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+    const groups = await this.getApi().availableCuratorGroups()
+    const availableGroupIds = groups
+      .filter(
+        ([groupId, group]) =>
+          group.active.valueOf() &&
+          classMaintainers.every((maintainers) => maintainers.some((m) => m.eq(groupId))) &&
+          group.curators.toArray().some((curatorId) => curatorId.eq(curator.workerId))
+      )
+      .map(([id]) => id)
+
+    let groupId: number
+    if (!availableGroupIds.length) {
+      this.error(
+        'You do not have the required maintainer access to at least one of the following classes: ' +
+          classNames.join(', '),
+        { exit: ExitCodes.AccessDenied }
+      )
+    } else if (availableGroupIds.length === 1) {
+      groupId = availableGroupIds[0].toNumber()
+    } else {
+      groupId = await this.promptForCuratorGroup('Select Curator Group context', availableGroupIds)
     }
+
+    return createType('Actor', { Curator: [groupId, curator.workerId.toNumber()] })
   }
 
   async promptForClass(message = 'Select a class'): Promise<Class> {

+ 27 - 16
cli/src/base/WorkingGroupsCommandBase.ts

@@ -24,29 +24,20 @@ const DEFAULT_GROUP = WorkingGroups.StorageProviders
 const DRAFTS_FOLDER = 'opening-drafts'
 
 /**
- * Abstract base class for commands related to working groups
+ * Abstract base class for commands that need to use gates based on user's roles
  */
-export default abstract class WorkingGroupsCommandBase extends AccountsCommandBase {
+export abstract class RolesCommandBase extends AccountsCommandBase {
   group: WorkingGroups = DEFAULT_GROUP
 
-  static flags = {
-    group: flags.string({
-      char: 'g',
-      description:
-        'The working group context in which the command should be executed\n' +
-        `Available values are: ${AvailableGroups.join(', ')}.`,
-      required: true,
-      default: DEFAULT_GROUP,
-    }),
-  }
-
   // Use when lead access is required in given command
   async getRequiredLead(): Promise<GroupMember> {
     const selectedAccount: NamedKeyringPair = await this.getRequiredSelectedAccount()
     const lead = await this.getApi().groupLead(this.group)
 
     if (!lead || lead.roleAccount.toString() !== selectedAccount.address) {
-      this.error('Lead access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Lead access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     }
 
     return lead
@@ -59,7 +50,9 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
     const groupMembersByAccount = groupMembers.filter((m) => m.roleAccount.toString() === selectedAccount.address)
 
     if (!groupMembersByAccount.length) {
-      this.error('Worker access required for this command!', { exit: ExitCodes.AccessDenied })
+      this.error(`${_.startCase(this.group)} Group Worker access required for this command!`, {
+        exit: ExitCodes.AccessDenied,
+      })
     } else if (groupMembersByAccount.length === 1) {
       return groupMembersByAccount[0]
     } else {
@@ -88,7 +81,7 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
   async promptForWorker(groupMembers: GroupMember[]): Promise<GroupMember> {
     const chosenWorkerIndex = await this.simplePrompt({
-      message: 'Choose the intended worker context:',
+      message: `Choose the intended ${_.startCase(this.group)} Group Worker context:`,
       type: 'list',
       choices: groupMembers.map((groupMember, index) => ({
         name: `Worker ID ${groupMember.workerId.toString()}`,
@@ -98,6 +91,24 @@ export default abstract class WorkingGroupsCommandBase extends AccountsCommandBa
 
     return groupMembers[chosenWorkerIndex]
   }
+}
+
+/**
+ * Abstract base class for commands directly related to working groups
+ */
+export default abstract class WorkingGroupsCommandBase extends RolesCommandBase {
+  group: WorkingGroups = DEFAULT_GROUP
+
+  static flags = {
+    group: flags.string({
+      char: 'g',
+      description:
+        'The working group context in which the command should be executed\n' +
+        `Available values are: ${AvailableGroups.join(', ')}.`,
+      required: true,
+      default: DEFAULT_GROUP,
+    }),
+  }
 
   async promptForApplicationsToAccept(opening: GroupOpening): Promise<number[]> {
     const acceptableApplications = opening.applications.filter((a) => a.stage === ApplicationStageKeys.Active)

+ 1 - 1
cli/src/commands/media/createChannel.ts

@@ -27,7 +27,7 @@ export default class CreateChannelCommand extends ContentDirectoryCommandBase {
     if (!inputJson) {
       const customPrompts: JsonSchemaCustomPrompts = [
         ['language', () => this.promptForEntityId('Choose channel language', 'Language', 'name')],
-        ['curationStatus', async () => undefined],
+        ['isCensored', async () => undefined],
       ]
 
       const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, undefined, customPrompts)

+ 57 - 0
cli/src/commands/media/curateContent.ts

@@ -0,0 +1,57 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { InputParser } from 'cd-schemas'
+import { flags } from '@oclif/command'
+import { ChannelEntity } from 'cd-schemas/types/entities/ChannelEntity'
+import { VideoEntity } from 'cd-schemas/types/entities/VideoEntity'
+
+const CLASSES = ['Channel', 'Video'] as const
+const STATUSES = ['Accepted', 'Censored'] as const
+
+export default class CurateContentCommand extends ContentDirectoryCommandBase {
+  static description = `Set the curation status of given entity (${CLASSES.join('/')}). Requires Curator access.`
+  static flags = {
+    className: flags.enum({
+      options: [...CLASSES],
+      description: `Name of the class of the entity to curate (${CLASSES.join('/')})`,
+      char: 'c',
+      required: true,
+    }),
+    status: flags.enum({
+      description: `Specifies the curation status (${STATUSES.join('/')})`,
+      char: 's',
+      options: [...STATUSES],
+      required: true,
+    }),
+    id: flags.integer({
+      description: 'ID of the entity to curate',
+      required: true,
+    }),
+  }
+
+  async run() {
+    const { className, status, id } = this.parse(CurateContentCommand).flags
+
+    const account = await this.getRequiredSelectedAccount()
+    // Get curator actor with required maintainer access to $className (Video/Channel) class
+    const actor = await this.getCuratorContext([className])
+
+    await this.requestAccountDecoding(account)
+
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+
+    await this.getEntity(id, className) // Check if entity exists and is of given class
+
+    const entityUpdateInput: Partial<ChannelEntity & VideoEntity> = {
+      isCensored: status === 'Censored',
+    }
+
+    this.log(`Updating the ${className} with:`)
+    this.jsonPrettyPrint(JSON.stringify(entityUpdateInput))
+    const confirmed = await this.simplePrompt({ type: 'confirm', message: 'Do you confirm the provided input?' })
+
+    if (confirmed) {
+      const operations = await inputParser.getEntityUpdateOperations(entityUpdateInput, className, id)
+      await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations], true)
+    }
+  }
+}

+ 27 - 7
cli/src/commands/media/updateChannel.ts

@@ -5,12 +5,18 @@ import { InputParser } from 'cd-schemas'
 import { IOFlags, getInputJson, saveOutputJson } from '../../helpers/InputOutput'
 import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { Entity } from '@joystream/types/content-directory'
+import { Actor, Entity } from '@joystream/types/content-directory'
+import { flags } from '@oclif/command'
+import { createType } from '@joystream/types'
 
 export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
   static description = 'Update one of the owned channels on Joystream (requires a membership).'
   static flags = {
     ...IOFlags,
+    asCurator: flags.boolean({
+      description: 'Provide this flag in order to use Curator context for the update',
+      required: false,
+    }),
   }
 
   static args = [
@@ -22,13 +28,23 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
   ]
 
   async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateChannelCommand)
+
     const account = await this.getRequiredSelectedAccount()
-    const memberId = await this.getRequiredMemberId()
-    const actor = { Member: memberId }
 
-    await this.requestAccountDecoding(account)
+    let memberId: number | undefined, actor: Actor
+
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Channel'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
 
-    const { id } = this.parse(UpdateChannelCommand).args
+    await this.requestAccountDecoding(account)
 
     let channelEntity: Entity, channelId: number
     if (id) {
@@ -49,15 +65,19 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
 
     let inputJson = await getInputJson<ChannelEntity>(input, channelJsonSchema)
     if (!inputJson) {
-      const customPrompts: JsonSchemaCustomPrompts = [
+      const customPrompts: JsonSchemaCustomPrompts<ChannelEntity> = [
         [
           'language',
           () =>
             this.promptForEntityId('Choose channel language', 'Language', 'name', undefined, currentValues.language),
         ],
-        ['curationStatus', async () => undefined],
       ]
 
+      if (!asCurator) {
+        // Skip isCensored is it's not updated by the curator
+        customPrompts.push(['isCensored', async () => undefined])
+      }
+
       const prompter = new JsonSchemaPrompter<ChannelEntity>(channelJsonSchema, currentValues, customPrompts)
 
       inputJson = await prompter.promptAll()

+ 25 - 5
cli/src/commands/media/updateVideo.ts

@@ -5,12 +5,18 @@ import { LicenseEntity } from 'cd-schemas/types/entities/LicenseEntity'
 import { InputParser } from 'cd-schemas'
 import { JSONSchema } from '@apidevtools/json-schema-ref-parser'
 import { JsonSchemaCustomPrompts, JsonSchemaPrompter } from '../../helpers/JsonSchemaPrompt'
-import { Entity } from '@joystream/types/content-directory'
+import { Actor, Entity } from '@joystream/types/content-directory'
+import { createType } from '@joystream/types'
+import { flags } from '@oclif/command'
 
 export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
   static description = 'Update existing video information (requires a membership).'
   static flags = {
     // TODO: ...IOFlags, - providing input as json
+    asCurator: flags.boolean({
+      description: 'Specify in order to update the video as curator',
+      required: false,
+    }),
   }
 
   static args = [
@@ -22,13 +28,23 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
   ]
 
   async run() {
+    const {
+      args: { id },
+      flags: { asCurator },
+    } = this.parse(UpdateVideoCommand)
+
     const account = await this.getRequiredSelectedAccount()
-    const memberId = await this.getRequiredMemberId()
-    const actor = { Member: memberId }
 
-    await this.requestAccountDecoding(account)
+    let memberId: number | undefined, actor: Actor
 
-    const { id } = this.parse(UpdateVideoCommand).args
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Video', 'License'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
+
+    await this.requestAccountDecoding(account)
 
     let videoEntity: Entity, videoId: number
     if (id) {
@@ -80,6 +96,10 @@ export default class UpdateVideoCommand extends ContentDirectoryCommandBase {
       'hasMarketing',
     ])
 
+    if (asCurator) {
+      updatedProps.isCensored = await videoPrompter.promptSingleProp('isCensored')
+    }
+
     this.jsonPrettyPrint(JSON.stringify(updatedProps))
 
     // Parse inputs into operations and send final extrinsic

+ 0 - 7
content-directory-schemas/inputs/classes/CurationStatusClass.json

@@ -1,7 +0,0 @@
-{
-  "name": "CurationStatus",
-  "description": "Curation status of a related entity (ie. Video or Channel)",
-  "maximum_entities_count": 400,
-  "default_entity_creation_voucher_upper_bound": 50,
-  "class_permissions": { "any_member": false }
-}

+ 3 - 3
content-directory-schemas/inputs/schemas/ChannelSchema.json

@@ -33,11 +33,11 @@
       "property_type": { "Single": "Bool" }
     },
     {
-      "name": "curationStatus",
-      "description": "Channel curation status set by the Curator",
+      "name": "isCensored",
+      "description": "Channel censorship status set by the Curator.",
       "required": false,
       "unique": true,
-      "property_type": { "Single": { "Reference": { "className": "CurationStatus" } } },
+      "property_type": { "Single": "Bool" },
       "locking_policy": { "is_locked_from_controller": true }
     },
     {

+ 0 - 27
content-directory-schemas/inputs/schemas/CurationStatusSchema.json

@@ -1,27 +0,0 @@
-{
-  "className": "CurationStatus",
-  "newProperties": [
-    {
-      "name": "approved",
-      "description": "Indicates whether the content was approved by the Curator",
-      "required": false,
-      "property_type": { "Single": "Bool" },
-      "locking_policy": { "is_locked_from_controller": true }
-    },
-    {
-      "name": "comment",
-      "description": "Short, optional comment from the Curator",
-      "required": false,
-      "property_type": { "Single": { "Text": 256 } },
-      "locking_policy": { "is_locked_from_controller": true }
-    },
-    {
-      "name": "entityId",
-      "description": "ID of the curated entity. It's not a relation to prevent removal lock and allow different types of entities. Used to confirm the validity of Content => CurationStatus relation.",
-      "required": true,
-      "unique": true,
-      "property_type": { "Single": "Uint64" },
-      "locking_policy": { "is_locked_from_controller": true }
-    }
-  ]
-}

+ 3 - 3
content-directory-schemas/inputs/schemas/VideoSchema.json

@@ -90,11 +90,11 @@
       "property_type": { "Single": { "Reference": { "className": "License", "sameOwner": true } } }
     },
     {
-      "name": "curationStatus",
-      "description": "Video curation status set by the Curator",
+      "name": "isCensored",
+      "description": "Video censorship status set by the Curator.",
       "required": false,
       "unique": true,
-      "property_type": { "Single": { "Reference": { "className": "CurationStatus" } } },
+      "property_type": { "Single": "Bool" },
       "locking_policy": { "is_locked_from_controller": true }
     }
   ]