refactor: server hooks
This commit is contained in:
		
							parent
							
								
									fca6977ad1
								
							
						
					
					
						commit
						159775ff54
					
				@ -17,9 +17,13 @@ describe('server hooks', () => {
 | 
			
		||||
        'x-uid': 'table',
 | 
			
		||||
        'x-component': 'Table',
 | 
			
		||||
        'x-collection': 'posts',
 | 
			
		||||
        'x-server-hooks': {
 | 
			
		||||
          afterDestroyCollection: ['onCollectionDestroy'],
 | 
			
		||||
        },
 | 
			
		||||
        'x-server-hooks': [
 | 
			
		||||
          {
 | 
			
		||||
            type: 'onCollectionDestroy',
 | 
			
		||||
            collection: 'posts',
 | 
			
		||||
            method: 'onCollectionDestroy',
 | 
			
		||||
          },
 | 
			
		||||
        ],
 | 
			
		||||
        properties: {
 | 
			
		||||
          col1: {
 | 
			
		||||
            'x-uid': 'col1',
 | 
			
		||||
@ -29,9 +33,14 @@ describe('server hooks', () => {
 | 
			
		||||
                'x-uid': 'field1',
 | 
			
		||||
                'x-component': 'Input',
 | 
			
		||||
                'x-collection-field': 'posts.title',
 | 
			
		||||
                'x-server-hooks': {
 | 
			
		||||
                  afterDestroyField: ['onFieldDestroy'],
 | 
			
		||||
                },
 | 
			
		||||
                'x-server-hooks': [
 | 
			
		||||
                  {
 | 
			
		||||
                    type: 'onCollectionFieldDestroy',
 | 
			
		||||
                    collection: 'posts',
 | 
			
		||||
                    fields: ['title'],
 | 
			
		||||
                    method: 'onFieldDestroy',
 | 
			
		||||
                  },
 | 
			
		||||
                ],
 | 
			
		||||
              },
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
@ -63,29 +72,7 @@ describe('server hooks', () => {
 | 
			
		||||
    uiSchemaPlugin = app.getPlugin<PluginUiSchema>('PluginUiSchema');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should save uiSchemaAttrs', async () => {
 | 
			
		||||
    const node = await uiSchemaRepository.findOne({
 | 
			
		||||
      filter: {
 | 
			
		||||
        uid: 'table',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    const nodeAttr = await node.getAttrs();
 | 
			
		||||
    expect(nodeAttr.get('collectionPath')).toEqual('posts');
 | 
			
		||||
 | 
			
		||||
    const nodeField = await uiSchemaRepository.findOne({
 | 
			
		||||
      filter: {
 | 
			
		||||
        uid: 'field1',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    const nodeFieldAttr = await nodeField.getAttrs();
 | 
			
		||||
    expect(nodeFieldAttr.get('collectionPath')).toEqual('posts.title');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should call server hooks afterFieldDestroy', async () => {
 | 
			
		||||
  it('should call server hooks onFieldDestroy', async () => {
 | 
			
		||||
    const PostModel = await db.getRepository('collections').create({
 | 
			
		||||
      values: {
 | 
			
		||||
        name: 'posts',
 | 
			
		||||
@ -106,7 +93,7 @@ describe('server hooks', () => {
 | 
			
		||||
    const serverHooks = uiSchemaPlugin.serverHooks;
 | 
			
		||||
    const hookFn = jest.fn();
 | 
			
		||||
 | 
			
		||||
    serverHooks.register('afterDestroyField', 'onFieldDestroy', hookFn);
 | 
			
		||||
    serverHooks.register('onCollectionFieldDestroy', 'onFieldDestroy', hookFn);
 | 
			
		||||
 | 
			
		||||
    // destroy a field
 | 
			
		||||
    await db.getRepository('fields').destroy({
 | 
			
		||||
@ -119,7 +106,7 @@ describe('server hooks', () => {
 | 
			
		||||
    expect(hookFn).toHaveBeenCalled();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should call server hooks afterCollectionDestroy', async () => {
 | 
			
		||||
  it('should call server hooks onCollectionDestroy', async () => {
 | 
			
		||||
    const PostModel = await db.getRepository('collections').create({
 | 
			
		||||
      values: {
 | 
			
		||||
        name: 'posts',
 | 
			
		||||
@ -141,7 +128,7 @@ describe('server hooks', () => {
 | 
			
		||||
 | 
			
		||||
    const hookFn = jest.fn();
 | 
			
		||||
 | 
			
		||||
    serverHooks.register('afterDestroyCollection', 'onCollectionDestroy', hookFn);
 | 
			
		||||
    serverHooks.register('onCollectionDestroy', 'onCollectionDestroy', hookFn);
 | 
			
		||||
 | 
			
		||||
    // destroy a field
 | 
			
		||||
    await db.getRepository('collections').destroy({
 | 
			
		||||
@ -154,18 +141,21 @@ describe('server hooks', () => {
 | 
			
		||||
    expect(hookFn).toHaveBeenCalled();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should call server hooks afterUiSchemaCreated', async () => {
 | 
			
		||||
  it('should call server hooks onUiSchemaCreate', async () => {
 | 
			
		||||
    const menuSchema = {
 | 
			
		||||
      'x-uid': 'menu',
 | 
			
		||||
      'x-server-hooks': {
 | 
			
		||||
        afterCreateSelf: ['afterCreateMenu'],
 | 
			
		||||
      },
 | 
			
		||||
      'x-server-hooks': [
 | 
			
		||||
        {
 | 
			
		||||
          type: 'onSelfCreate',
 | 
			
		||||
          method: 'afterCreateMenu',
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const serverHooks = uiSchemaPlugin.serverHooks;
 | 
			
		||||
    const hookFn = jest.fn();
 | 
			
		||||
 | 
			
		||||
    serverHooks.register('afterCreateSelf', 'afterCreateMenu', hookFn);
 | 
			
		||||
    serverHooks.register('onSelfCreate', 'afterCreateMenu', hookFn);
 | 
			
		||||
 | 
			
		||||
    await uiSchemaRepository.create({
 | 
			
		||||
      values: {
 | 
			
		||||
@ -175,4 +165,71 @@ describe('server hooks', () => {
 | 
			
		||||
 | 
			
		||||
    expect(hookFn).toHaveBeenCalled();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('should rollback after throw error', async () => {
 | 
			
		||||
    const testSchema = {
 | 
			
		||||
      'x-uid': 'test',
 | 
			
		||||
      'x-collection-field': 'posts.title',
 | 
			
		||||
      'x-server-hooks': [
 | 
			
		||||
        {
 | 
			
		||||
          type: 'onCollectionFieldDestroy',
 | 
			
		||||
          collection: 'posts',
 | 
			
		||||
          fields: ['title'],
 | 
			
		||||
          method: 'preventDestroy',
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    await uiSchemaRepository.create({
 | 
			
		||||
      values: {
 | 
			
		||||
        schema: testSchema,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const PostModel = await db.getRepository('collections').create({
 | 
			
		||||
      values: {
 | 
			
		||||
        name: 'posts',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const fieldModel = await db.getRepository('fields').create({
 | 
			
		||||
      values: {
 | 
			
		||||
        name: 'title',
 | 
			
		||||
        type: 'string',
 | 
			
		||||
        collectionName: 'posts',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // @ts-ignore
 | 
			
		||||
    await PostModel.migrate();
 | 
			
		||||
 | 
			
		||||
    const serverHooks = uiSchemaPlugin.serverHooks;
 | 
			
		||||
 | 
			
		||||
    const jestFn = jest.fn();
 | 
			
		||||
 | 
			
		||||
    serverHooks.register('onCollectionFieldDestroy', 'preventDestroy', async ({ options }) => {
 | 
			
		||||
      await options.transaction.rollback();
 | 
			
		||||
      jestFn();
 | 
			
		||||
      throw new Error('cant delete field');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // destroy a field
 | 
			
		||||
      await db.getRepository('fields').destroy({
 | 
			
		||||
        filter: {
 | 
			
		||||
          name: 'title',
 | 
			
		||||
        },
 | 
			
		||||
        individualHooks: true,
 | 
			
		||||
      });
 | 
			
		||||
    } catch (e) {}
 | 
			
		||||
 | 
			
		||||
    expect(jestFn).toHaveBeenCalled();
 | 
			
		||||
    expect(
 | 
			
		||||
      await db.getRepository('fields').findOne({
 | 
			
		||||
        filter: {
 | 
			
		||||
          name: 'title',
 | 
			
		||||
        },
 | 
			
		||||
      }),
 | 
			
		||||
    ).toBeDefined();
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -1,13 +0,0 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'uiSchemaAttrs',
 | 
			
		||||
  autoGenId: false,
 | 
			
		||||
  timestamps: false,
 | 
			
		||||
  fields: [
 | 
			
		||||
    {
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'collectionPath',
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
@ -0,0 +1,24 @@
 | 
			
		||||
import { CollectionOptions } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: 'uiSchemaServerHooks',
 | 
			
		||||
  model: 'ServerHookModel',
 | 
			
		||||
  // autoGenId: false,
 | 
			
		||||
  timestamps: false,
 | 
			
		||||
  fields: [
 | 
			
		||||
    { type: 'belongsTo', name: 'uiSchema', target: 'ui_schemas', foreignKey: 'uid' },
 | 
			
		||||
    { type: 'string', name: 'type' },
 | 
			
		||||
    {
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'collection',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: 'array',
 | 
			
		||||
      name: 'fields',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: 'string',
 | 
			
		||||
      name: 'method',
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
} as CollectionOptions;
 | 
			
		||||
@ -26,9 +26,9 @@ export default {
 | 
			
		||||
      name: 'name',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      type: 'hasOne',
 | 
			
		||||
      name: 'attrs',
 | 
			
		||||
      target: 'uiSchemaAttrs',
 | 
			
		||||
      type: 'hasMany',
 | 
			
		||||
      name: 'serverHooks',
 | 
			
		||||
      target: 'uiSchemaServerHooks',
 | 
			
		||||
      foreignKey: 'uid',
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
 | 
			
		||||
@ -3,8 +3,10 @@ import { HookType } from './server-hooks';
 | 
			
		||||
 | 
			
		||||
class UiSchemaModel extends MagicAttributeModel {
 | 
			
		||||
  getListenServerHooks(type: HookType) {
 | 
			
		||||
    const hooks = this.get('x-server-hooks') || {};
 | 
			
		||||
    return hooks[type] || [];
 | 
			
		||||
    const hooks = this.get('x-server-hooks') || [];
 | 
			
		||||
    return hooks.filter((hook) => {
 | 
			
		||||
      hook.type = '';
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -386,33 +386,22 @@ export default class UiSchemaRepository extends Repository {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async insertSchemaRecord(name, uid, schema, transaction) {
 | 
			
		||||
    const serverHooks = schema['x-server-hooks'] || [];
 | 
			
		||||
 | 
			
		||||
    const node = await this.create({
 | 
			
		||||
      values: {
 | 
			
		||||
        name,
 | 
			
		||||
        uid,
 | 
			
		||||
        schema,
 | 
			
		||||
        serverHooks,
 | 
			
		||||
      },
 | 
			
		||||
      transaction,
 | 
			
		||||
      hooks: false,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    await this.insertSchemaAttrs(node, transaction);
 | 
			
		||||
    return node;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async insertSchemaAttrs(schema, transaction) {
 | 
			
		||||
    const collectionPath = schema.get('x-collection') || schema.get('x-collection-field');
 | 
			
		||||
 | 
			
		||||
    if (collectionPath) {
 | 
			
		||||
      await this.database.getRepository('uiSchemaAttrs').create({
 | 
			
		||||
        values: {
 | 
			
		||||
          uid: schema.get('uid'),
 | 
			
		||||
          collectionPath: collectionPath,
 | 
			
		||||
        },
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async insertSingleNode(schema: SchemaNode, transaction: Transaction) {
 | 
			
		||||
    const db = this.database;
 | 
			
		||||
    const treeCollection = db.getCollection('ui_schema_tree_path');
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,12 @@
 | 
			
		||||
import { Database } from '@nocobase/database';
 | 
			
		||||
import { UiSchemaModel } from '../model';
 | 
			
		||||
import { ServerHookModel } from './model';
 | 
			
		||||
 | 
			
		||||
export type HookType = 'afterDestroyField' | 'afterDestroyCollection' | 'afterCreateSelf';
 | 
			
		||||
export type HookType =
 | 
			
		||||
  | 'onSchemaDestroy'
 | 
			
		||||
  | 'onCollectionDestroy'
 | 
			
		||||
  | 'onCollectionFieldDestroy'
 | 
			
		||||
  | 'onAnyCollectionFieldDestroy'
 | 
			
		||||
  | 'onSelfCreate';
 | 
			
		||||
 | 
			
		||||
export class ServerHooks {
 | 
			
		||||
  hooks = new Map<HookType, Map<string, any>>();
 | 
			
		||||
@ -12,80 +17,82 @@ export class ServerHooks {
 | 
			
		||||
 | 
			
		||||
  listen() {
 | 
			
		||||
    this.db.on('fields.afterDestroy', async (model, options) => {
 | 
			
		||||
      await this.afterFieldDestroy(model, options);
 | 
			
		||||
      await this.onCollectionFieldDestroy(model, options);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.db.on('collections.afterDestroy', async (model, options) => {
 | 
			
		||||
      await this.afterCollectionDestroy(model, options);
 | 
			
		||||
      await this.onCollectionDestroy(model, options);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    this.db.on('ui_schemas.afterCreate', async (model, options) => {
 | 
			
		||||
      await this.afterUiSchemaCreated(model, options);
 | 
			
		||||
    this.db.on('ui_schemas.afterCreateWithAssociations', async (model, options) => {
 | 
			
		||||
      await this.onUiSchemaCreate(model, options);
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected async afterUiSchemaCreated(uiSchemaModel, options) {
 | 
			
		||||
  protected async onCollectionDestroy(collectionModel, options) {
 | 
			
		||||
    const { transaction } = options;
 | 
			
		||||
    const listenHooksName = uiSchemaModel.getListenServerHooks('afterCreateSelf');
 | 
			
		||||
 | 
			
		||||
    for (const listenHookName of listenHooksName) {
 | 
			
		||||
      const hookFunc = this.hooks.get('afterCreateSelf')?.get(listenHookName);
 | 
			
		||||
 | 
			
		||||
      await hookFunc({
 | 
			
		||||
        schemaInstance: uiSchemaModel,
 | 
			
		||||
    await this.findHooksAndCall(
 | 
			
		||||
      {
 | 
			
		||||
        type: 'onCollectionDestroy',
 | 
			
		||||
        collection: collectionModel.get('name'),
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        collectionModel,
 | 
			
		||||
        options,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected async afterCollectionDestroy(collectionModel, options) {
 | 
			
		||||
    const { transaction } = options;
 | 
			
		||||
 | 
			
		||||
    const collectionPath = collectionModel.get('name');
 | 
			
		||||
 | 
			
		||||
    const listenSchemas = (await this.db.getRepository('ui_schemas').find({
 | 
			
		||||
      filter: {
 | 
			
		||||
        'attrs.collectionPath': collectionPath,
 | 
			
		||||
      },
 | 
			
		||||
      transaction,
 | 
			
		||||
    })) as UiSchemaModel[];
 | 
			
		||||
 | 
			
		||||
    for (const listenSchema of listenSchemas) {
 | 
			
		||||
      const listenHooksName = listenSchema.getListenServerHooks('afterDestroyCollection');
 | 
			
		||||
      for (const listenHookName of listenHooksName) {
 | 
			
		||||
        const hookFunc = this.hooks.get('afterDestroyCollection')?.get(listenHookName);
 | 
			
		||||
 | 
			
		||||
        await hookFunc({
 | 
			
		||||
          collectionInstance: collectionModel,
 | 
			
		||||
          schemaInstance: listenSchema,
 | 
			
		||||
          options,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected async afterFieldDestroy(fieldModel, options) {
 | 
			
		||||
  protected async onCollectionFieldDestroy(fieldModel, options) {
 | 
			
		||||
    const { transaction } = options;
 | 
			
		||||
    const collectionName = fieldModel.get('collectionName');
 | 
			
		||||
    const fieldName = fieldModel.get('name');
 | 
			
		||||
 | 
			
		||||
    const collectionPath = `${fieldModel.get('collectionName')}.${fieldModel.get('name')}`;
 | 
			
		||||
 | 
			
		||||
    const listenSchemas = (await this.db.getRepository('ui_schemas').find({
 | 
			
		||||
      filter: {
 | 
			
		||||
        'attrs.collectionPath': collectionPath,
 | 
			
		||||
    await this.findHooksAndCall(
 | 
			
		||||
      {
 | 
			
		||||
        type: 'onCollectionFieldDestroy',
 | 
			
		||||
        collection: collectionName,
 | 
			
		||||
        'fields.$anyOf': [fieldName],
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        fieldInstance: fieldModel,
 | 
			
		||||
        options,
 | 
			
		||||
      },
 | 
			
		||||
      transaction,
 | 
			
		||||
    })) as UiSchemaModel[];
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
    for (const listenSchema of listenSchemas) {
 | 
			
		||||
      const listenHooksName = listenSchema.getListenServerHooks('afterDestroyField');
 | 
			
		||||
      for (const listenHookName of listenHooksName) {
 | 
			
		||||
        const hookFunc = this.hooks.get('afterDestroyField')?.get(listenHookName);
 | 
			
		||||
  protected async onUiSchemaCreate(uiSchemaModel, options) {
 | 
			
		||||
    const { transaction } = options;
 | 
			
		||||
 | 
			
		||||
        await hookFunc({
 | 
			
		||||
          fieldInstance: fieldModel,
 | 
			
		||||
          schemaInstance: listenSchema,
 | 
			
		||||
          options,
 | 
			
		||||
        });
 | 
			
		||||
    await this.findHooksAndCall(
 | 
			
		||||
      {
 | 
			
		||||
        type: 'onSelfCreate',
 | 
			
		||||
        uid: uiSchemaModel.schema['x-uid'],
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        uiSchemaModel,
 | 
			
		||||
        options,
 | 
			
		||||
      },
 | 
			
		||||
      transaction,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  protected async findHooksAndCall(hooksFilter, hooksArgs, transaction) {
 | 
			
		||||
    const hooks = (await this.db.getRepository('uiSchemaServerHooks').find({
 | 
			
		||||
      filter: hooksFilter,
 | 
			
		||||
      appends: ['uiSchema'],
 | 
			
		||||
      transaction,
 | 
			
		||||
    })) as ServerHookModel[];
 | 
			
		||||
 | 
			
		||||
    for (const hookRecord of hooks) {
 | 
			
		||||
      const hoodMethodName = hookRecord.get('method') as string;
 | 
			
		||||
      const hookFunc = this.hooks.get(hookRecord.get('type') as HookType)?.get(hoodMethodName);
 | 
			
		||||
 | 
			
		||||
      if (hookFunc) {
 | 
			
		||||
        await hookFunc({ ...hooksArgs, schemaInstance: (<any>hookRecord).uiSchema });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,3 @@
 | 
			
		||||
import { Model } from '@nocobase/database';
 | 
			
		||||
 | 
			
		||||
export class ServerHookModel extends Model {}
 | 
			
		||||
@ -5,6 +5,7 @@ import { uiSchemaActions } from './actions/ui-schema-action';
 | 
			
		||||
import UiSchemaRepository from './repository';
 | 
			
		||||
import { ServerHooks } from './server-hooks';
 | 
			
		||||
import { UiSchemaModel } from './model';
 | 
			
		||||
import { ServerHookModel } from './server-hooks/model';
 | 
			
		||||
 | 
			
		||||
export default class PluginUiSchema extends Plugin {
 | 
			
		||||
  serverHooks: ServerHooks;
 | 
			
		||||
@ -20,7 +21,7 @@ export default class PluginUiSchema extends Plugin {
 | 
			
		||||
 | 
			
		||||
    this.serverHooks = new ServerHooks(db);
 | 
			
		||||
 | 
			
		||||
    this.app.db.registerModels({ MagicAttributeModel, UiSchemaModel });
 | 
			
		||||
    this.app.db.registerModels({ MagicAttributeModel, UiSchemaModel, ServerHookModel });
 | 
			
		||||
 | 
			
		||||
    this.registerRepository();
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user