Browse Source

Merge remote-tracking branch 'upstream/babylon' into cli/bump-version

Mokhtar Naamani 4 years ago
parent
commit
b371bedd50

+ 37 - 1
cli/README.md

@@ -44,7 +44,7 @@ $ npm install -g @joystream/cli
 $ joystream-cli COMMAND
 running command...
 $ joystream-cli (-v|--version|version)
-@joystream/cli/0.3.0 darwin-x64 node-v12.18.2
+@joystream/cli/0.3.0 linux-x64 node-v12.18.2
 $ joystream-cli --help [COMMAND]
 USAGE
   $ joystream-cli COMMAND
@@ -83,6 +83,7 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli content-directory:classes`](#joystream-cli-content-directoryclasses)
 * [`joystream-cli content-directory:createClass`](#joystream-cli-content-directorycreateclass)
 * [`joystream-cli content-directory:createCuratorGroup`](#joystream-cli-content-directorycreatecuratorgroup)
+* [`joystream-cli content-directory:createEntity CLASSNAME`](#joystream-cli-content-directorycreateentity-classname)
 * [`joystream-cli content-directory:curatorGroup ID`](#joystream-cli-content-directorycuratorgroup-id)
 * [`joystream-cli content-directory:curatorGroups`](#joystream-cli-content-directorycuratorgroups)
 * [`joystream-cli content-directory:entities CLASSNAME [PROPERTIES]`](#joystream-cli-content-directoryentities-classname-properties)
@@ -94,6 +95,7 @@ When using the CLI for the first time there are a few common steps you might wan
 * [`joystream-cli content-directory:removeMaintainerFromClass [CLASSNAME] [GROUPID]`](#joystream-cli-content-directoryremovemaintainerfromclass-classname-groupid)
 * [`joystream-cli content-directory:setCuratorGroupStatus [ID] [STATUS]`](#joystream-cli-content-directorysetcuratorgroupstatus-id-status)
 * [`joystream-cli content-directory:updateClassPermissions [CLASSNAME]`](#joystream-cli-content-directoryupdateclasspermissions-classname)
+* [`joystream-cli content-directory:updateEntityPropertyValues ID`](#joystream-cli-content-directoryupdateentitypropertyvalues-id)
 * [`joystream-cli council:info`](#joystream-cli-councilinfo)
 * [`joystream-cli help [COMMAND]`](#joystream-cli-help-command)
 * [`joystream-cli media:createChannel`](#joystream-cli-mediacreatechannel)
@@ -423,6 +425,23 @@ ALIASES
 
 _See code: [src/commands/content-directory/createCuratorGroup.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createCuratorGroup.ts)_
 
+## `joystream-cli content-directory:createEntity CLASSNAME`
+
+Creates a new entity in the specified class (can be executed in Member, Curator or Lead context)
+
+```
+USAGE
+  $ joystream-cli content-directory:createEntity CLASSNAME
+
+ARGUMENTS
+  CLASSNAME  Name or ID of the Class
+
+OPTIONS
+  --context=(Member|Curator|Lead)  Actor context to execute the command in (Member/Curator/Lead)
+```
+
+_See code: [src/commands/content-directory/createEntity.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/createEntity.ts)_
+
 ## `joystream-cli content-directory:curatorGroup ID`
 
 Show Curator Group details by ID.
@@ -588,6 +607,23 @@ ARGUMENTS
 
 _See code: [src/commands/content-directory/updateClassPermissions.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/updateClassPermissions.ts)_
 
+## `joystream-cli content-directory:updateEntityPropertyValues ID`
+
+Updates the property values of the specified entity (can be executed in Member, Curator or Lead context)
+
+```
+USAGE
+  $ joystream-cli content-directory:updateEntityPropertyValues ID
+
+ARGUMENTS
+  ID  ID of the Entity
+
+OPTIONS
+  --context=(Member|Curator|Lead)  Actor context to execute the command in (Member/Curator/Lead)
+```
+
+_See code: [src/commands/content-directory/updateEntityPropertyValues.ts](https://github.com/Joystream/joystream/blob/master/cli/src/commands/content-directory/updateEntityPropertyValues.ts)_
+
 ## `joystream-cli council:info`
 
 Get current council and council elections information

+ 124 - 2
cli/src/base/ContentDirectoryCommandBase.ts

@@ -12,6 +12,7 @@ import {
   EntityId,
   Actor,
   PropertyType,
+  Property,
 } from '@joystream/types/content-directory'
 import { Worker } from '@joystream/types/working-group'
 import { CLIError } from '@oclif/errors'
@@ -23,6 +24,7 @@ import { RolesCommandBase } from './WorkingGroupsCommandBase'
 import { createType } from '@joystream/types'
 import chalk from 'chalk'
 import { flags } from '@oclif/command'
+import { DistinctQuestion } from 'inquirer'
 
 const CONTEXTS = ['Member', 'Curator', 'Lead'] as const
 type Context = typeof CONTEXTS[number]
@@ -247,7 +249,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
 
   async getAndParseKnownEntity<T>(id: string | number, className?: string): Promise<FlattenRelations<T>> {
     const entity = await this.getEntity(id, className)
-    return this.parseToKnownEntityJson<T>(entity)
+    return this.parseToEntityJson<T>(entity)
   }
 
   async entitiesByClassAndOwner(classNameOrId: number | string, ownerMemberId?: number): Promise<[EntityId, Entity][]> {
@@ -339,7 +341,7 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
     }, {} as Record<string, ParsedPropertyValue>)
   }
 
-  async parseToKnownEntityJson<T>(entity: Entity): Promise<FlattenRelations<T>> {
+  async parseToEntityJson<T = unknown>(entity: Entity): Promise<FlattenRelations<T>> {
     const entityClass = (await this.classEntryByNameOrId(entity.class_id.toString()))[1]
     return (_.mapValues(this.parseEntityPropertyValues(entity, entityClass), (v) =>
       this.parseStoredPropertyInnerValue(v.value)
@@ -376,4 +378,124 @@ export default abstract class ContentDirectoryCommandBase extends RolesCommandBa
 
     return parsedEntities.filter((entity) => filters.every(([pName, pValue]) => entity[pName] === pValue))
   }
+
+  async getActor(context: typeof CONTEXTS[number], pickedClass: Class) {
+    let actor: Actor
+    if (context === 'Member') {
+      const memberId = await this.getRequiredMemberId()
+      actor = this.createType('Actor', { Member: memberId })
+    } else if (context === 'Curator') {
+      actor = await this.getCuratorContext([pickedClass.name.toString()])
+    } else {
+      await this.getRequiredLead()
+
+      actor = this.createType('Actor', { Lead: null })
+    }
+
+    return actor
+  }
+
+  isActorEntityController(actor: Actor, entity: Entity, isMaintainer: boolean): boolean {
+    const entityController = entity.entity_permissions.controller
+    return (
+      (isMaintainer && entityController.isOfType('Maintainers')) ||
+      (entityController.isOfType('Member') &&
+        actor.isOfType('Member') &&
+        entityController.asType('Member').eq(actor.asType('Member'))) ||
+      (entityController.isOfType('Lead') && actor.isOfType('Lead'))
+    )
+  }
+
+  async isEntityPropertyEditableByActor(entity: Entity, classPropertyId: number, actor: Actor): Promise<boolean> {
+    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
+
+    const isActorMaintainer =
+      actor.isOfType('Curator') &&
+      entityClass.class_permissions.maintainers.toArray().some((groupId) => groupId.eq(actor.asType('Curator')[0]))
+
+    const isActorController = this.isActorEntityController(actor, entity, isActorMaintainer)
+
+    const {
+      is_locked_from_controller: isLockedFromController,
+      is_locked_from_maintainer: isLockedFromMaintainer,
+    } = entityClass.properties[classPropertyId].locking_policy
+
+    return (
+      (isActorController && !isLockedFromController.valueOf()) ||
+      (isActorMaintainer && !isLockedFromMaintainer.valueOf())
+    )
+  }
+
+  getQuestionsFromProperties(properties: Property[], defaults?: { [key: string]: unknown }): DistinctQuestion[] {
+    return properties.reduce((previousValue, { name, property_type: propertyType, required }) => {
+      const propertySubtype = propertyType.subtype
+      const questionType = propertySubtype === 'Bool' ? 'list' : 'input'
+      const isSubtypeNumber = propertySubtype.toLowerCase().includes('int')
+      const isSubtypeReference = propertyType.isOfType('Single') && propertyType.asType('Single').isOfType('Reference')
+
+      const validate = async (answer: string | number | null) => {
+        if (answer === null) {
+          return true // Can only happen through "filter" if property is not required
+        }
+
+        if ((isSubtypeNumber || isSubtypeReference) && parseInt(answer.toString()).toString() !== answer.toString()) {
+          return `Expected integer value!`
+        }
+
+        if (isSubtypeReference) {
+          try {
+            await this.getEntity(+answer, propertyType.asType('Single').asType('Reference')[0].toString())
+          } catch (e) {
+            return e.message || JSON.stringify(e)
+          }
+        }
+
+        return true
+      }
+
+      const optionalQuestionProperties = {
+        ...{
+          filter: async (answer: string) => {
+            if (required.isFalse && !answer) {
+              return null
+            }
+
+            // Only cast to number if valid
+            // Prevents inquirer bug not allowing to edit invalid values when casted to number
+            // See: https://github.com/SBoudrias/Inquirer.js/issues/866
+            if ((isSubtypeNumber || isSubtypeReference) && (await validate(answer)) === true) {
+              return parseInt(answer)
+            }
+
+            return answer
+          },
+          validate,
+        },
+        ...(propertySubtype === 'Bool' && {
+          choices: ['true', 'false'],
+          filter: (answer: string) => {
+            return answer === 'true' || false
+          },
+        }),
+      }
+
+      const isQuestionOptional = propertySubtype === 'Bool' ? '' : required.isTrue ? '(required)' : '(optional)'
+      const classId = isSubtypeReference
+        ? ` [Class Id: ${propertyType.asType('Single').asType('Reference')[0].toString()}]`
+        : ''
+
+      return [
+        ...previousValue,
+        {
+          name: name.toString(),
+          message: `${name} - ${propertySubtype}${classId} ${isQuestionOptional}`,
+          type: questionType,
+          ...optionalQuestionProperties,
+          ...(defaults && {
+            default: propertySubtype === 'Bool' ? JSON.stringify(defaults[name.toString()]) : defaults[name.toString()],
+          }),
+        },
+      ]
+    }, [] as DistinctQuestion[])
+  }
 }

+ 1 - 1
cli/src/base/MediaCommandBase.ts

@@ -23,7 +23,7 @@ export default abstract class MediaCommandBase extends ContentDirectoryCommandBa
     })
     if (licenseType === 'known') {
       const [id, knownLicenseEntity] = await this.promptForEntityEntry('Choose License', 'KnownLicense', 'code')
-      const knownLicense = await this.parseToKnownEntityJson<KnownLicenseEntity>(knownLicenseEntity)
+      const knownLicense = await this.parseToEntityJson<KnownLicenseEntity>(knownLicenseEntity)
       licenseInput = { knownLicense: id.toNumber() }
       if (knownLicense.attributionRequired) {
         licenseInput.attribution = await this.simplePrompt({ message: 'Attribution' })

+ 58 - 0
cli/src/commands/content-directory/createEntity.ts

@@ -0,0 +1,58 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import inquirer from 'inquirer'
+import { InputParser } from '@joystream/cd-schemas'
+import ExitCodes from '../../ExitCodes'
+
+export default class CreateEntityCommand extends ContentDirectoryCommandBase {
+  static description =
+    'Creates a new entity in the specified class (can be executed in Member, Curator or Lead context)'
+
+  static args = [
+    {
+      name: 'className',
+      required: true,
+      description: 'Name or ID of the Class',
+    },
+  ]
+
+  static flags = {
+    context: ContentDirectoryCommandBase.contextFlag,
+  }
+
+  async run() {
+    const { className } = this.parse(CreateEntityCommand).args
+    let { context } = this.parse(CreateEntityCommand).flags
+
+    if (!context) {
+      context = await this.promptForContext()
+    }
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(currentAccount)
+    const [, entityClass] = await this.classEntryByNameOrId(className)
+
+    const actor = await this.getActor(context, entityClass)
+
+    if (actor.isOfType('Member') && entityClass.class_permissions.any_member.isFalse) {
+      this.error('Choosen actor has no access to create an entity of this type', { exit: ExitCodes.AccessDenied })
+    }
+
+    const answers: {
+      [key: string]: string | number | null
+    } = await inquirer.prompt(this.getQuestionsFromProperties(entityClass.properties.toArray()))
+
+    this.jsonPrettyPrint(JSON.stringify(answers))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi(), [
+      {
+        className: entityClass.name.toString(),
+        entries: [answers],
+      },
+    ])
+
+    const operations = await inputParser.getEntityBatchOperations()
+
+    await this.sendAndFollowNamedTx(currentAccount, 'contentDirectory', 'transaction', [actor, operations])
+  }
+}

+ 3 - 15
cli/src/commands/content-directory/removeEntity.ts

@@ -1,6 +1,5 @@
 import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
 import { Actor } from '@joystream/types/content-directory'
-import { createType } from '@joystream/types'
 import ExitCodes from '../../ExitCodes'
 
 export default class RemoveEntityCommand extends ContentDirectoryCommandBase {
@@ -31,20 +30,9 @@ export default class RemoveEntityCommand extends ContentDirectoryCommandBase {
     }
 
     const account = await this.getRequiredSelectedAccount()
-    let actor: Actor
-    if (context === 'Curator') {
-      actor = await this.getCuratorContext([entityClass.name.toString()])
-    } else if (context === 'Member') {
-      const memberId = await this.getRequiredMemberId()
-      if (
-        !entity.entity_permissions.controller.isOfType('Member') ||
-        entity.entity_permissions.controller.asType('Member').toNumber() !== memberId
-      ) {
-        this.error('You are not the entity controller!', { exit: ExitCodes.AccessDenied })
-      }
-      actor = createType('Actor', { Member: memberId })
-    } else {
-      actor = createType('Actor', { Lead: null })
+    const actor: Actor = await this.getActor(context, entityClass)
+    if (!actor.isOfType('Curator') && !this.isActorEntityController(actor, entity, false)) {
+      this.error('You are not the entity controller!', { exit: ExitCodes.AccessDenied })
     }
 
     await this.requireConfirmation(

+ 61 - 0
cli/src/commands/content-directory/updateEntityPropertyValues.ts

@@ -0,0 +1,61 @@
+import ContentDirectoryCommandBase from '../../base/ContentDirectoryCommandBase'
+import inquirer from 'inquirer'
+import { InputParser } from '@joystream/cd-schemas'
+import ExitCodes from '../../ExitCodes'
+
+export default class UpdateEntityPropertyValues extends ContentDirectoryCommandBase {
+  static description =
+    'Updates the property values of the specified entity (can be executed in Member, Curator or Lead context)'
+
+  static args = [
+    {
+      name: 'id',
+      required: true,
+      description: 'ID of the Entity',
+    },
+  ]
+
+  static flags = {
+    context: ContentDirectoryCommandBase.contextFlag,
+  }
+
+  async run() {
+    const { id } = this.parse(UpdateEntityPropertyValues).args
+    let { context } = this.parse(UpdateEntityPropertyValues).flags
+
+    if (!context) {
+      context = await this.promptForContext()
+    }
+
+    const currentAccount = await this.getRequiredSelectedAccount()
+    await this.requestAccountDecoding(currentAccount)
+
+    const entity = await this.getEntity(id)
+    const [, entityClass] = await this.classEntryByNameOrId(entity.class_id.toString())
+    const defaults = await this.parseToEntityJson(entity)
+
+    const actor = await this.getActor(context, entityClass)
+
+    const isPropertEditableByIndex = await Promise.all(
+      entityClass.properties.map((p, i) => this.isEntityPropertyEditableByActor(entity, i, actor))
+    )
+    const filteredProperties = entityClass.properties.filter((p, i) => isPropertEditableByIndex[i])
+
+    if (!filteredProperties.length) {
+      this.error('No entity properties are editable by choosen actor', { exit: ExitCodes.AccessDenied })
+    }
+
+    const answers: {
+      [key: string]: string | number | null
+    } = await inquirer.prompt(this.getQuestionsFromProperties(filteredProperties, defaults))
+
+    this.jsonPrettyPrint(JSON.stringify(answers))
+    await this.requireConfirmation('Do you confirm the provided input?')
+
+    const inputParser = InputParser.createWithKnownSchemas(this.getOriginalApi())
+
+    const operations = await inputParser.getEntityUpdateOperations(answers, entityClass.name.toString(), +id)
+
+    await this.sendAndFollowNamedTx(currentAccount, 'contentDirectory', 'transaction', [actor, operations])
+  }
+}

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

@@ -11,7 +11,7 @@ export default class FeaturedVideosCommand extends ContentDirectoryCommandBase {
     const featured = await Promise.all(
       featuredEntries
         .filter(([, entity]) => entity.supported_schemas.toArray().length) // Ignore FeaturedVideo entities without schema
-        .map(([, entity]) => this.parseToKnownEntityJson<FeaturedVideoEntity>(entity))
+        .map(([, entity]) => this.parseToEntityJson<FeaturedVideoEntity>(entity))
     )
 
     const videoIds: number[] = featured.map(({ video: videoId }) => videoId)

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

@@ -33,7 +33,7 @@ export default class RemoveChannelCommand extends ContentDirectoryCommandBase {
       channelId = id.toNumber()
       channelEntity = channel
     }
-    const channel = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+    const channel = await this.parseToEntityJson<ChannelEntity>(channelEntity)
 
     await this.requireConfirmation(`Are you sure you want to remove "${channel.handle}" channel?`)
 

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

@@ -34,7 +34,7 @@ export default class RemoveVideoCommand extends ContentDirectoryCommandBase {
       videoEntity = video
     }
 
-    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const video = await this.parseToEntityJson<VideoEntity>(videoEntity)
 
     await this.requireConfirmation(`Are you sure you want to remove the "${video.title}" video?`)
 

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

@@ -57,7 +57,7 @@ export default class UpdateChannelCommand extends ContentDirectoryCommandBase {
       channelEntity = channel
     }
 
-    const currentValues = await this.parseToKnownEntityJson<ChannelEntity>(channelEntity)
+    const currentValues = await this.parseToEntityJson<ChannelEntity>(channelEntity)
     this.jsonPrettyPrint(JSON.stringify(currentValues))
 
     const channelJsonSchema = (ChannelEntitySchema as unknown) as JSONSchema

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

@@ -55,7 +55,7 @@ export default class UpdateVideoCommand extends MediaCommandBase {
       videoEntity = video
     }
 
-    const currentValues = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const currentValues = await this.parseToEntityJson<VideoEntity>(videoEntity)
     const videoJsonSchema = (VideoEntitySchema as unknown) as JSONSchema
 
     const {

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

@@ -37,7 +37,7 @@ export default class UpdateVideoLicenseCommand extends MediaCommandBase {
       videoEntity = video
     }
 
-    const video = await this.parseToKnownEntityJson<VideoEntity>(videoEntity)
+    const video = await this.parseToEntityJson<VideoEntity>(videoEntity)
     const currentLicense = await this.getAndParseKnownEntity<LicenseEntity>(video.license)
 
     this.log('Current license:', currentLicense)

+ 4 - 1
setup.sh

@@ -16,7 +16,10 @@ rustup component add rustfmt clippy
 rustup install nightly-2020-05-23 --force
 rustup target add wasm32-unknown-unknown --toolchain nightly-2020-05-23
 
-# Sticking with older version of compiler to ensure working build
+# Latest clippy linter which comes with 1.47.0 fails on some subtrate modules
+# Also note combination of newer versions of toolchain with the above nightly
+# toolchain to build wasm seems to fail.
+# So we need to stick with an older version until we update substrate
 rustup install 1.46.0
 rustup default 1.46.0