Ver código fonte

Curation actions - add/change CurationStatus, update channel/video as curator

Leszek Wiesner 4 anos atrás
pai
commit
a2ced8a48c

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

@@ -1,27 +1,63 @@
 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'
 
 /**
  * Abstract base class for commands related to working groups
  */
-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]) =>
+          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)

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

@@ -0,0 +1,95 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import { CurationStatusEntity } from 'cd-schemas/types/entities/CurationStatusEntity'
+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', 'Rejected'] 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,
+    }),
+    rationale: flags.string({
+      description: 'Optional rationale',
+      char: 'r',
+      required: false,
+    }),
+    id: flags.integer({
+      description: 'ID of the entity to curate',
+      required: true,
+    }),
+  }
+
+  async run() {
+    const { className, status, id, rationale } = this.parse(CurateContentCommand).flags
+
+    const account = await this.getRequiredSelectedAccount()
+    // Get curator actor with required maintainer access to $className and 'CurationStatus' classes
+    const actor = await this.getCuratorContext([className, 'CurationStatus'])
+
+    await this.requestAccountDecoding(account)
+
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+
+    await this.getEntity(id, className) // Check if entity exists and is of given class
+
+    // Check if CurationStatus for this entity already exists
+    let existingStatusId: number | undefined
+    try {
+      existingStatusId = await inputParser.findEntityIdByUniqueQuery({ entityId: id }, 'CurationStatus')
+    } catch (e) {
+      /* Continue */
+    }
+
+    if (existingStatusId) {
+      const current = await this.getAndParseKnownEntity<CurationStatusEntity>(existingStatusId)
+      const statusUpdate: Partial<CurationStatusEntity> = { approved: status === 'Accepted', comment: rationale }
+
+      this.log(`Existing curation status found!`)
+      this.jsonPrettyPrint(JSON.stringify(current))
+      this.log('It will be updated to...')
+      this.jsonPrettyPrint(JSON.stringify({ ...current, ...statusUpdate }))
+      const confirmed = await this.simplePrompt({
+        type: 'confirm',
+        message: `Do you confirm this operation?`,
+      })
+
+      if (confirmed) {
+        const operations = await inputParser.getEntityUpdateOperations(statusUpdate, 'CurationStatus', existingStatusId)
+        await this.sendAndFollowNamedTx(account, 'contentDirectory', 'transaction', [actor, operations], true)
+      }
+    } else {
+      const curationStatus: CurationStatusEntity = {
+        entityId: id,
+        approved: status === 'Accepted',
+        comment: rationale,
+      }
+
+      const entityUpdateInput: Partial<ChannelEntity & VideoEntity> = {
+        curationStatus: { new: curationStatus },
+      }
+
+      this.jsonPrettyPrint(JSON.stringify(curationStatus))
+      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)
+      }
+    }
+  }
+}

+ 21 - 5
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
 
-    const { id } = this.parse(UpdateChannelCommand).args
+    if (asCurator) {
+      actor = await this.getCuratorContext(['Channel'])
+    } else {
+      memberId = await this.getRequiredMemberId()
+      actor = createType('Actor', { Member: memberId })
+    }
+
+    await this.requestAccountDecoding(account)
 
     let channelEntity: Entity, channelId: number
     if (id) {

+ 21 - 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) {