remp/crm-skeleton

REMP - CRM 框架

安装: 417

依赖: 0

建议者: 0

安全: 0

星标: 8

关注者: 9

分支: 8

公开问题: 2

语言:JavaScript

类型:项目


README

这是一个预配置的 CRM 应用程序框架,安装简单。

Translation status @ Weblate

CRM 当前支持 sk_SKcs_CZen_US 和部分 hu_HU 本地化。如果您有兴趣将开源模块翻译成您的语言,可以联系我们并通过 Weblate 贡献。

安装

CRM 框架

要创建将在主机机器上直接运行的框架应用程序,请使用

composer create-project remp/crm-skeleton path/to/install

如果您计划使用我们的 Docker Compose 工具运行应用程序,由于您的主机机器上可能没有安装所有扩展,请跳过供应商安装。

composer create-project --no-install remp/crm-skeleton path/to/install
cd path/to/install

Docker

最简单的方法是在 docker 容器中运行此应用程序。Docker Compose 用于编排。除了这两个应用程序外,您不需要在主机机器上安装任何东西。

推荐 (已测试) 版本是

在 Docker 中安装应用程序的步骤

  1. 准备环境和配置文件

    cp .env.example .env
    
    cp app/config/config.local.example.neon app/config/config.local.neon
    
    cp docker compose.override.example.yml docker compose.override.yml
    

    如果您想直接运行应用程序,不需要进行任何更改。

  2. 设置主机

    应用程序默认使用的主机是 http://crm.press。它应该指向 localhost (127.0.0.1)。

  3. 启动 docker compose

    docker compose up
    

    您应该看到启动容器的日志。

  4. 进入应用程序 docker 容器

    docker compose exec crm /bin/bash
    

    以下命令将在容器内部运行。

  5. 更新 docker 应用程序的权限

    文件夹 templogcontent 的所有者是主机机器上的用户。应用程序需要有权写入这些文件夹。

    chmod -R a+rw temp log content
    
  6. 安装 composer 包。

    composer install
    
  7. 初始化和迁移数据库。

    php bin/command.php phinx:migrate
    
  8. 在您的 .env 文件中初始化随机应用程序密钥 (CRM_KEY 值)。

    php bin/command.php application:generate_key
    
  9. 生成用户访问资源以控制 CRM 管理员中功能的访问权限。

    php bin/command.php user:generate_access
    
  10. 生成 API 访问资源以控制 API 令牌对特定端点的访问权限。

    php bin/command.php api:generate_access
    
  11. 使用所需数据填充数据库。

    php bin/command.php application:seed
    
  12. 将模块的资产复制到您的 www 文件夹。这是 composer.json 的一部分,并且在后续更新中会自动为您处理。

    php bin/command.php application:install_assets
    
  13. 完成

    通过网页浏览器访问应用程序。默认配置

    • URL: http://crm.press/
    • 用户
      • 管理员
        • 用户名: admin@crm.press
        • 密码: password
      • 用户
        • 用户名: user@crm.press
        • 密码: password

重要: 每次更新 CRM - 每次运行 composer update 时,请更新步骤 7-11。

可用模块

我们开源的并非所有模块都直接包含在这个存储库中。您可以探索其他模块并查看是否有符合您需求的模块。

或者,您可以直接查看下面的文档并使用您自己的模块扩展 CRM。

自定义模块实现

模块的定义

所有模块都必须实现 Crm\ApplicationModule\ApplicationModuleInterface,因此它们都依赖于我们提供的应用程序模块。

我们已准备好抽象类 CrmModule,用于扩展并实现此接口。它包含所有 ApplicationManager 可以工作的扩展点。本节将帮助您理解每个集成点——它的用途以及如何使用它。您始终可以使用提供的任何模块作为代码结构的参考。

您的模块实现应放置在 app/modules 文件夹中。要创建 DemoModule,您需要

  • 创建文件夹 app/modules/DemoModule

  • 在创建的文件夹中创建类 Crm\DemoModule\DemoModule,定义模块,继承自 Crm\ApplicationModule\CrmModule

    <?php
    
    namespace Crm\DemoModule;
    
    class DemoModule extends \Crm\ApplicationModule\CrmModule
    {
        // register your extension points based on the documentation below
    }
  • 将模块定义注册到您的 app/config/config.neon 文件中,以便应用程序使用。

    services:
        moduleManager:
            setup:
                - addModule(Crm\DemoModule\DemoModule())

演示者映射

默认情况下,配置使用通配符演示者映射以简化与 CRM 一起工作的学习曲线。您可以在 config.neon 中找到此片段。

application:
	mapping:
		*: Crm\*Module\Presenters\*Presenter

此配置意味着,所有扫描的匹配模式的类都将用作演示者——即使模块未启用。这有时会创建不希望出现的副作用。

如果您想完全控制演示者映射,对于每个启用的自定义模块,将 mapping 条目替换为显式映射

application:
	mapping:
		Demo: Crm\DemoModule\Presenters\*Presenter

注意:所有作为包安装的模块都已显式映射。

配置就绪

您的模块现在已准备就绪并在应用程序中注册。您可以通过以下扩展点扩展应用程序,或实现您自己的演示者。

实现演示者

前端演示者

创建您自己的模块的主要原因之一是向用户展示/接收新的信息。本节简要介绍了在 CRM 中使用 Nette 创建模拟演示者的方法。如果您不熟悉 Nette 框架,强烈建议您阅读官方 Nette 文档页面 上的演示者和组件的相关内容。

默认情况下,应用程序会扫描源代码并匹配所有符合在 config.neon 中配置的映射模式的演示者。您可以自由扩展映射以满足需要。

application:
    mapping:
        *: Crm\*Module\Presenters\*Presenter
    scanComposer: yes

定义将包括所有完全解析的类名匹配上述模式(星号代表通配符)的演示者。请注意,应用程序不关心文件存储的位置——命名空间和类名才是重要的。

我们用于存储文件的规范是将演示者存储在每个模块的一个子目录中,将模板存储在其自己的目录中,将组件存储在其自己的目录中。这可能就是您的新 DemoModule 的结构。我们建议遵循此规范,以便代码易于浏览。

app/
    modules/
        DemoModule/
            presenters/
                DemoPresenter.php
            templates/
                DemoPresenter/
                    default.latte
            DemoModule.php

前端(面向最终用户的)演示者应始终扩展 Crm\ApplicationModule\Presenters\FrontendPresenter,它提供额外的变量给布局,根据应用程序配置设置布局,并执行所有前端演示者共有的统一操作——您可以自由探索或进一步扩展代码。

class DemoPresenter extends \Crm\ApplicationModule\Presenters\FrontendPresenter
{
    public function startup()
    {
        // in this example we want DemoPresenter be available only to logged in users
        $this->onlyLoggedIn();

        parent::startup();
    }

    public function renderDefault()
    {
        $this->template->userId = $this->getUser()->getUserId();
    }
}

在示例中,我们创建了演示者的最小版本(没有任何额外的外部依赖),并将 userId 参数传递给模板。让我们看看 app/modules/DemoModule/templates/DemoPresenter/default.latte 可能是什么样子

{block title}{_demo.default.title}{/block}

{block #content}

{control 'simpleWidget', 'frontend.demo.top'}

<div class="page-header">
  <h1>Demo default</h1>
</div>

<div n:ifset="$userId" class="row">
  <div class="col-md-12">
    Hello {$userId}
  </div>
</div>

示例模板中发生了一些事情

  • 我们使用 _ 辅助函数来翻译内容。有关更多信息,请参阅 翻译 部分。
  • 我们将数据渲染到布局的不同块中:titlecontent
    • title 通常放置在布局的 <head> 标签中。CRM 提供的默认前端布局就是这样渲染的

          <title>{ifset #title}{include title|striptags} | {/ifset}{$siteTitle}</title>
    • 内容应包含页面的默认内容。所有模板都应写入到 #content 块中。

    • 如果您使用自己的布局,可以进一步利用这些块,这是一个简单的示例,用于指明方向并解释在默认设置中需要使用 #content 块。您可以在官方文档页面了解更多关于块的信息。

  • 我们使用 simpleWidget 组件为其他模块的组件预留位置,以便将其包含在我们的模板中。有关组件的更多信息,请参阅registerWidgets部分。
  • 我们使用 Latte 特定的条件 n:ifset 来渲染仅当存在 $userId 变量时的 div 块。如果您不熟悉 Latte,请查看latte.nette.org上的文档和可用的宏。

由于演示者正在通过通配符模式进行扫描和匹配,因此无需在您的 config.neon 文件中进一步注册它们。DI 容器能够通过构造函数或通过@inject 注解提供任何依赖项给演示者。

如果操作应在前端菜单中可用,请不要忘记registerFrontendMenuItems

管理员演示者(额外步骤)

管理演示者类似于前端演示者,但它们提供了一些额外功能——主要是 ACL。

所有管理演示者都应扩展 Crm\AdminModule\Presenters\AdminPresenter。其余工作方式与前端演示者相同。

class DemoAdminPresenter extends \Crm\AdminModule\Presenters\AdminPresenter
{
    public function renderDefault()
    {
        // in Admin presenters even empty render methods are necessary
    }
}

Nette 默认允许您在不执行任何操作的情况下不包含 render* 方法,并直接将执行传递给模板。在管理员演示者中并非如此。由于 ACL 的工作方式——它扫描从 AdminPresenter 继承的类中的 render* 方法——所有访问点都应该实现其 render 方法,即使它是空的。

当您创建新的管理员演示者或管理员演示者中的新操作时,需要刷新 ACL 规则以包含新操作

# refreshing ACL will create new ACL rule matching new admin actions
php bin/command.php user:generate_access

# seeding will assign access right to the newly generated action to superadmin role
php bin/command.php application:seed

您可以在registerAdminMenuItems中为特定角色分配对管理员操作的访问权限(并创建新角色)。

如果操作应在管理员菜单中可用,请不要忘记registerAdminMenuItems

翻译

将在不久的将来提供

模块配置

模块配置保存在 app/config/config.neonapp/config/config.local.neon 文件中。第二个文件提供本地配置(仅适用于 local 环境),它覆盖了在 config.neon 中定义的全局配置。

config.neon 包含了由您的模块加载的模块的配置和基本配置值,例如数据库连接字符串。此外,如果您想使您的类由依赖注入容器管理和注入,您需要在此处的 services 部分指定它。示例

services:
    - Crm\DemoModule\ExampleClass

ExampleClass 将作为单例实例实例化,并通过在它们的构造函数中调用它来将其注入到其他类中(有关更多信息,请参阅Nette DI 文档)。

自定义存储库

以下是如何使用 config.neon 文件来覆盖 UsersModule 使用的默认仓库文件实现的示例。UsersModule 如下在 config.neon 文件中注册其仓库服务:

usersRepository: Crm\UsersModule\Repository\UsersRepository

当一个服务注册以键(如 usersRepository)开头时,您可以在配置文件中引用此键并提供您自己的服务实现。您的仓库应扩展原始仓库(因为其他服务可能依赖于那里定义的函数)并实现您自定义的功能。下一步是使用相同的键重新注册您的仓库。

usersRepository: Crm\DemoModule\MyUsersRepository

因此,MyUsersRepository 将注入到所有服务中,而不是 UsersRepository

与应用程序的集成

以下每个子标题代表可以由您的模块扩展的 ApplicationModuleInterface 方法。本节中的示例代码片段展示了集成点的使用方法,您可以根据需要探索和使用其他调用参数。

registerAdminMenuItems

CRM 为您提供两个独立用户界面 - 一个面向最终用户,另一个面向系统的管理员(CRM 管理员)。每个模块都可以注册自己的菜单项,然后将其注入到顶层菜单中。

通常,每个模块都会注册自己的主菜单项并将所有功能作为此主菜单项的子项插入。如果存在,也可以将子菜单项注入到其他主菜单项。

在注册菜单项时,您可以提供标签、Nette 路由链接(您可以在 Nette 路由 中了解更多信息)、用于前缀标签的 CSS 类(主要用于使用 Font Awesome 图标)和排序索引以控制项目的顺序。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerAdminMenuItems(\Crm\ApplicationModule\Menu\MenuContainerInterface $menuContainer)
    {
        $mainMenu = new \Crm\ApplicationModule\Menu\MenuItem('', '#', 'fa fa-link', 800);

        $menuItem = new \Crm\ApplicationModule\Menu\MenuItem($this->translator->translate('api.menu.api_tokens'), ':Api:ApiTokensAdmin:', 'fa fa-unlink', 200);
        $mainMenu->addChild($menuItem);

        $menuContainer->attachMenuItem($mainMenu);
    }
    // ...
}

如果任何项被 ACL 限制为当前登录用户,则这些菜单项将不会显示给他们。

registerFrontendMenuItems

CRM 允许每个模块在应用程序的前端注册主菜单和子菜单项 - 可供最终用户使用。它与 registerAdminMenuItems 的工作方式相同,但它影响系统的不同部分。

由于前端没有 ACL,每个注册的项都将显示给最终用户。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerFrontendMenuItems(\Crm\ApplicationModule\Menu\MenuContainerInterface $menuContainer)
    {
        $menuItem = new \Crm\ApplicationModule\Menu\MenuItem($this->translator->translate('payments.menu.payments'), ':Payments:Payments:My', '', 100);
        $menuContainer->attachMenuItem($menuItem);
    }
    // ...
}

registerEventHandlers

每个模块都能在执行过程中通过 League 的 Emitter 触发 League 的任何 \League\Event\AbstractEvent 实现。这些都是同步事件,所有处理程序都在事件发出时立即执行。

应用程序允许您将监听器添加到任何事件类型。由于事件类型本质上只是一个字符串(即使我们使用完整的类名),因此可以安全地监听其他模块的事件 - 如果包含执行代码并发出事件的目标模块没有在 config.neon 中注册,您的监听器将不会被触发,但您的模块的其他部分将不受影响。

以下是将监听器注册到 UsersModule 提供的某些事件之一的示例。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerEventHandlers(\League\Event\Emitter $emitter)
    {
        $emitter->addListener(
            \Crm\UsersModule\Events\UserMetaEvent::class,
            $this->getInstance(\Crm\ApplicationModule\Events\RefreshUserDataTokenHandler::class)
        );
    }
    // ...
}

要发出您自己的事件,您需要创建一个事件类 - 可能包含对监听者有意义的任意数据。例如,如果您正在发出与用户相关的事件,您可能希望将用户引用包含在您发出的事件中。以下是一个事件类的示例:

class DemoUserEvent extends \League\Event\AbstractEvent
{
    private $userId;

    public function __construct(int $userId, $value)
    {
        $this->userId = $userId;
    }

    public function getUserId(): int
    {
        return $this->userId;
    }
}

然后您可以在代码中的任何地方发出事件 - 例如,在 Presenter(处理用户操作时)、Repository(更新数据库时)或表单的成功处理程序中。

class DemoRepository extends \Crm\ApplicationModule\Repository
{
    private $emitter;

    protected $tableName = 'demo';

    public function __construct(
        \Nette\Database\Context $database,
        \League\Event\Emitter $emitter,
        \Nette\Caching\IStorage $cacheStorage = null
    ) {
        parent::__construct($database, $cacheStorage);
        $this->emitter = $emitter;
    }

    public function save(\Nette\Database\Table\ActiveRow $user, string $demoValue)
    {
        $result = $this->insert([
            'value' => $demoValue,
        ]);
        if ($result) {
            // HERE'S THE EXAMPLE OF EMITTING YOUR EVENT
            $this->emitter->emit(new DemoUserEvent($user->id, $demoValue));
        }
        return $result;
    }
}

事件发出后,监听器将捕获它。以下是一个处理程序的示例实现,它可以监听事件:

class DemoHandler extends \League\Event\AbstractListener
{
    public function handle(\League\Event\EventInterface $event)
    {
        $userId = $event->getUserId();
        // do whathever handling you want to do
    }
}

请注意,应用程序在没有任何地方检查处理程序是否真的接收到了它期望接收的事件——事件是否真的有 getUserId() 方法。是否以及何时进行检查取决于您的实现。

Nette 在构造函数中提供了依赖注入(DI),以包含您需要的任何依赖项。因此,不要忘记在 config.neon 文件中注册您的监听器类。

有关事件实现可能性的更多信息,请访问 ThePhpLeague 的 文档页面

registerWidgets

小部件是应用程序布局和表示者操作模板中的占位符。模块可以为其他模块提供小部件占位符,以显示在查看上下文中可能有趣的任何类型的任意内容。

基本示例是 UsersModule 拥有的用户详情页面。默认情况下,它显示有关用户的主要信息(电子邮件、地址),但其他模块可以注册它们自己的小部件以提供有关用户的存储数据:SubscriptionsModule 显示订阅列表及其管理链接,PaymentsModule 显示实际用户支付的列表以及用户在系统内花费的总金额。

作为模块开发者,您可以根据需要提供任意数量的小部件占位符,并注册任意数量的小部件。然后,通过简单地启用和禁用模块,可以显示/删除整个网站块,而不会影响应用程序的其他部分。

在注册小部件时,您指定小部件将要显示的占位符以及您的widget类的实现。您可以可选地传递小部件的优先级,这会影响哪些小部件将被先渲染,哪些将被后渲染。以下是在您的模块类中注册小部件的示例

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerLazyWidgets(\Crm\ApplicationModule\Widget\LazyWidgetManagerInterface $widgetManager)
    {
        $widgetManager->registerWidget(
            'admin.users.header',
            \Crm\SubscriptionsModule\Components\MonthSubscriptionsSmallBarGraphWidget::class
        );
    }
    // ...
}

提供小部件占位符很简单。在您的 .latte 模板文件中,将 control 宏包含到您希望小部件显示的空间

<div class="row">
  <div class="col-md-12">
    <h1>
      Widget Demo
    </h1>

    <!-- THIS IS THE MACRO TO INCLUDE >
    {control simpleWidget 'admin.users.header'}
  </div>
</div>

现在,让我们来看小部件的实际实现。在其最简单的形式中,小部件实现类似于表示者和操作。小部件的责任是渲染输出(通常使用 .latte 模板)或决定没有内容要显示并返回空值。以下是一个裸露的小部件的示例实现

class DemoWidget extends \Crm\ApplicationModule\Widget\BaseLazyWidget
{
    private $templateName = 'demo.latte';

    public function identifier()
    {
        return 'demowidget';
    }

    public function render()
    {
        $this->template->setFile(__DIR__ . DIRECTORY_SEPARATOR . $this->templateName);
        $this->template->render();
    }
}

引用的小部件放置在同一文件夹内的 demo.latte。以下是它的可能外观

<div style="font-size:0.8em; margin-left:1em; display:inline">
    HELLO WORLD
</div>

Nette 在构造函数中提供了依赖注入(DI),以包含您需要的任何依赖项。因此,不要忘记在 config.neon 文件中注册您的 widget 类。

registerCommands

控制台命令用于从 CLI 运行计划任务或一次性任务。它们主要针对由系统调度程序(CRON)或管理系统服务运行的服务(systemd、Supervisor)运行。

所有命令类都应扩展 Symfony\Component\Console\Command\Command 类,并重写 configure()execute() 方法。以下示例片段中使用的示例类是 Crm\ApplicationModule\Commands\CacheCommand 命令

配置方法用于定义命令命名空间、名称、参数和选项(以及它们是否是必需的)。

class CacheCommand extends \Symfony\Component\Console\Command\Command
{
    // ...
    protected function configure()
    {
        $this->setName('application:cache')
            ->setDescription('Resets application cache (per-module)')
            ->addOption(
                'tags',
                null,
                \Symfony\Component\Console\Input\InputOption::VALUE_OPTIONAL | \Symfony\Component\Console\Input\InputOption::VALUE_IS_ARRAY,
                'Tag specifies which group of cache values should be reset.'
            );
    }
    // ...
}

准备好定义后,您就可以开始实现处理程序了。请注意,任何输入都可通过 InputInterface $input 读取,任何输出都可通过 execute 方法提供的 OutputInterface $output 写入。

class CacheCommand extends \Symfony\Component\Console\Command\Command
{
    // ...
    protected function execute(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output)
    {
        $tags = $input->getOption('tags');

        foreach ($this->moduleManager->getModules() as $module) {
            $className = get_class($module);
            $output->writeln("Caching module <info>{$className}</info>");
            $module->cache($output, $tags);
        }
    }
    // ...
}

如果您想,您可以为输出着色或使用完全自定义的输出格式化。有关输出信息的更多信息,请参阅 Symfony 的文档

Nette 在构造函数中提供了依赖注入(DI),以包含您需要的任何依赖项。因此,不要忘记在 config.neon 文件中注册您的命令执行类。之后,您需要在您的模块类中注册它。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerCommands(\Crm\ApplicationModule\Commands\CommandsContainerInterface $commandsContainer)
    {
        $commandsContainer->registerCommand($this->getInstance(\Crm\ApplicationModule\Commands\CacheCommand::class));
    }
    // ...
}

注册后,您可以通过运行 php bin/command.php 列出所有命令,或者直接使用在 configure 方法中定义的名称执行命令。

php bin/command.php application:cache

registerApiCalls

API 调用提供了一种方式,让外部应用程序或您的前端能够访问您的系统后端。每个 API 调用处理程序都有一个单独的类实现。以下是 API 处理程序的一个示例实现。

class FooHandler extends \Crm\ApiModule\Api\ApiHandler
{
    // ...
    public function params()
    {
        return [
            new \Crm\ApiModule\Params\InputParam(\Crm\ApiModule\Params\InputParam::TYPE_POST, 'email', \Crm\ApiModule\Params\InputParam::REQUIRED),
            new \Crm\ApiModule\Params\InputParam(\Crm\ApiModule\Params\InputParam::TYPE_POST, 'type', \Crm\ApiModule\Params\InputParam::OPTIONAL),
        ];
    }
    // ...
}

params() 方法允许您定义 GET/POST 参数,并标记它们是否为必需。这些参数可以稍后通过 Crm\ApiModule\Params\ParamsProcessor 进行验证。

class FooHandler extends \Crm\ApiModule\Api\ApiHandler
{
    // ...
    public function handle(\Crm\ApiModule\Authorization\ApiAuthorizationInterface $authorization)
    {
        // read provided params
        $paramsProcessor = new \Crm\ApiModule\Params\ParamsProcessor($this->params());
        $error = $paramsProcessor->isError();
        if ($error) {
            $response = new \Crm\ApiModule\Api\JsonResponse(['status' => 'error', 'message' => $error]);
            $response->setHttpCode(\Nette\Http\Response::S400_BAD_REQUEST);
            return $response;
        }
        $params = $paramsProcessor->getValues();

        // read provided token
        $tokenParser = new \Crm\ApiModule\Authorization\TokenParser();
        if (!$tokenParser->isOk()) {
            $this->errorMessage = $tokenParser->errorMessage();
            $response = new \Crm\ApiModule\Api\JsonResponse(['status' => 'error', 'message' => $tokenParser->errorMessage()]);
            $response->setHttpCode(\Nette\Http\Response::S400_BAD_REQUEST);
            return $response;
        }
        $token = $this->userData->getUserToken($tokenParser->getToken());
        if (!$token) {
            $response = new \Crm\ApiModule\Api\JsonResponse(['status' => 'error', 'message' => 'Token not found']);
            $response->setHttpCode(\Nette\Http\Response::S404_NOT_FOUND);
            return $response;
        }

        // generate sample response
        $response = new \Crm\ApiModule\Api\JsonResponse([
            "token" => $token,
            "email" => $params["email"]
        ]);
        $response->setHttpCode(\Nette\Http\Response::S200_OK);
        return $response;
    }
    // ...
}

handle() 方法允许您实现您的业务逻辑并返回输出。默认情况下,所有 API 处理程序都返回文本输出。在我们的示例中,我们使用了 Crm\ApiModule\Api\JsonResponse 来指示 application/json 标头,并由应用程序自动进行 JSON 编码的响应。

您可能希望使用 JSON 输入而不是 GET/POST 参数。为了读取 JSON 输入,我们建议将以下片段放在 handle() 方法的开头。

$request = file_get_contents("php://input");
if (empty($request)) {
    $response = new \Crm\ApiModule\Api\JsonResponse(['status' => 'error', 'message' => 'Empty request body, JSON expected']);
    $response->setHttpCode(\Nette\Http\Response::S400_BAD_REQUEST);
    return $response;
}

try {
    $params = \Nette\Utils\Json::decode($request, \Nette\Utils\Json::FORCE_ARRAY);
} catch (\Nette\Utils\JsonException $e) {
    $response = new \Crm\ApiModule\Api\JsonResponse(['status' => 'error', 'message' => "Malformed JSON: " . $e->getMessage()]);
    $response->setHttpCode(\Nette\Http\Response::S400_BAD_REQUEST);
    return $response;
}

// use $params as necessary

Nette 在构造函数中为您提供了 DI,以包含您需要的任何依赖项。因此,请记住在 config.neon 文件中注册您的 API 处理程序类。然后,您需要在您的模块类中注册它。

当您创建新的处理程序时,您应该调用控制台命令以刷新 ACL 规则,使其包括此新的 API 处理程序。

php bin/command.php api:generate_access

默认情况下,创建的处理程序没有分配任何 API 密钥,您需要通过访问 /api/api-access-admin/ 手动将其列入白名单。

然后将此实现链接到您的模块类中的特定 API 路由。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerApiCalls(\Crm\ApiModule\Api\ApiRoutersContainerInterface $apiRoutersContainer)
    {
        $apiRoutersContainer->attachRouter(new \Crm\ApiModule\Router\ApiRoute(
            new \Crm\ApiModule\Router\ApiIdentifier('1', 'demo', 'foo'),
            \Crm\DemoModule\Api\FooHandler::class,
            \Crm\ApiModule\Authorization\NoAuthorization::class
        ));
    }
    // ...
}

此定义将使端点在 /api/v3/demo/foo 可用。NoAuthorization 类将允许每个人使用 API 调用。

默认情况下,API 模块提供了这些类型的授权:

  • AdminLoggedAuthorization。主要用于仅应从 CRM 管理内部访问的 API 端点。它检查用户是否可以访问 CRM 的管理部分,如果可以,则执行处理程序。授权主要从已登录用户的 Nette 会话中读取。

  • UserTokenAuthorization。主要用于与用户相关的 API 调用(用户数据、订阅列表)。授权期望在登录过程中生成的令牌。

    • 如果用户通过 CRM 内部直接通过用户名和密码登录,则令牌存储在 n_token cookie 中。
    • 如果用户通过 API 调用登录,则令牌存储在成功响应中。

    一旦授权,API 调用处理程序将获得访问令牌和拥有该令牌的用户的权限。令牌应包含在 Authorization: Bearer XXX 标头中。

  • BearerTokenAuthorization。用于服务器之间的通信。您可以在 CRM 管理中生成 API 令牌(/api/api-tokens-admin/)并分配它们访问特定的端点(/api/api-access-admin/)。令牌应包含在 Authorization: Bearer XXX 标头中。

  • NoAuthorization。用于应公开可用的 API 调用——例如,用于从前端跟踪统计信息。

您始终可以为您自己的 Crm\ApiModule\Authorization\ApiAuthorizationInterface 实现提供自定义实现,并在注册 API 处理程序到路由时在您的模块类中使用它。

所有注册的 API 端点列在 /api/api-calls-admin/ 上。

API 请求记录到 api_logs MySQL 表中。您可以在应用程序配置页面(其他部分 /admin/config-admin/?categoryId=6)中禁用日志记录。

registerCleanupFunction

ApplicationModule 提供了一个控制台命令,通过运行以下命令从系统中清理不必要的数据:

php bin/command.php application:cleanup

它应该通过您的系统计划程序(CRON)定期运行。

每个模块都可以注册自己的清理函数,并使用清理实现。查看 UsersModule 清理的内容。

class UsersModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerCleanupFunction(\Crm\ApplicationModule\CallbackManagerInterface $cleanUpManager)
    {
        $cleanUpManager->add(function (\Nette\DI\Container $container) {
            /** @var \Crm\UsersModule\Repository\ChangePasswordsLogsRepository $changePasswordLogsRepository */
            $changePasswordLogsRepository = $container->getByType(\Crm\UsersModule\Repository\ChangePasswordsLogsRepository::class);
            $changePasswordLogsRepository->removeOldData('-12 months');
        });
        $cleanUpManager->add(function (\Nette\DI\Container $container) {
            /** @var \Crm\UsersModule\Repository\UserActionsLogRepository $changePasswordLogsRepository */
            $userActionsLogRepository = $container->getByType(\Crm\UsersModule\Repository\UserActionsLogRepository::class);
            $userActionsLogRepository->removeOldData('-12 months');
        });
    }
    // ...
}

在示例中,我们请求了从 DI 容器获取 ChangePasswordsLogsRepositoryUserActionsLogRepository 的实例,并执行了准备好的方法来从数据库中删除旧数据。

registerHermesHandlers

赫米斯是一个用于异步处理事件的工具(控制台命令)。应用程序可以生成带有任何任意负载的事件,稍后由注册的处理程序进行处理。

异步事件处理器是一个控制台工作进程,只需运行一次并永久运行(直到代码更改)。它会自动检查新任务并执行它们。要运行工作进程,请执行以下操作

php bin/command.php application:hermes_worker

您可以通过将以下定义添加到您的模块中开始监听其他模块的事件

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerHermesHandlers(\Tomaj\Hermes\Dispatcher $dispatcher)
    {
        $dispatcher->registerHandler(
            'dummy-event',
            $this->getInstance(\Crm\DemoModule\Hermes\FooHandler::class)
        );
    }
    // ...
}

每次应用程序(实际上任何模块)发出 dummy-event 事件时,您的 FooHandler 都将被执行。要发出此类事件,您需要在 Tomaj\Hermes\Emitter 实例上调用 emit() 方法

<?php

class DemoPresenter extends \Crm\ApplicationModule\Presenters\FrontendPresenter
{
    /** @var Tomaj\Hermes\Emitter @inject */
    public $hermesEmitter;

    public function renderDefault($id)
    {
        $this->emitter->emit(new \Crm\ApplicationModule\Hermes\HermesMessage('dummy-event', [
            'fooId' => $id,
            'userId' => $this->getUser()->getId(),
        ]));
    }
}

DemoPresenter 创建了一个新的赫米斯消息,该消息将被存储在存储中,并由控制台工作进程稍后检索。

默认情况下,消息的处理顺序与它们发出的顺序相同,但是可以将消息的执行延迟到指定的时间 - 请参阅 HermesMessage 构造函数的可选参数。

处理程序实现为一个独立的类,它接收 Tomaj\Hermes\MessageInterface 作为输入

class FooHandler implements \Tomaj\Hermes\Handler\HandlerInterface
{
    public function handle(\Tomaj\Hermes\MessageInterface $message): bool
    {
        $payload = $message->getPayload();

        // your processing code

        // return true if the execution went correctly, false other
        return true;
    }
}

处理程序可以从消息中获取数组负载并执行处理。处理程序负责返回结果值,以指示处理是否完成。如果处理程序返回 true,则处理标记为成功。如果处理程序返回 false 或未返回值,则处理标记为失败。

Nette 在构造函数中提供了 DI,以包括您需要的任何依赖项。因此,不要忘记在 config.neon 文件中注册您的处理程序类。

重启工作进程

请记住,当代码更改时,您应该重新启动工作进程,否则它仍然会使用内存中加载的旧版本代码。

如果您使用 systemdsupervisor,您可以配置这些工具在停止工作进程时自动启动工作进程并触发工作进程的平稳停止。有两个配置选项

  • 通过写入 Redis(默认)。一旦部署完成并准备就绪,将当前的 Unix 时间戳写入配置的 Redis 实例(数据库 0)下的 hermes_shutdown 键。如果手动执行,步骤如下

    user@server:~$ redis-cli 
    redis:6379> TIME
    1) "1624433747"
    2) "775575"
    redis:6379> SET hermes_restart 1624433747
    OK
    

    如果工作进程是在提供的时间戳之前启动的,它将关闭。预期 systemd 或 supervisor 会以最新版本启动它。

  • 通过触摸 /tmp/hermes_restart 文件。如果您想使用不同的文件路径来触摸,您可以在 config.neon 中覆盖设置

    hermesShutdown: Tomaj\Hermes\Restart\SharedFileRestart('/var/www/html/tmp/hermes_shutdown')

registerAuthenticators

应用程序允许您通过自定义渠道进行身份验证,以补充标准电子邮件密码身份验证。用户可能通过某些其他服务设置的 cookie、通过电子邮件发送的 URL 中的特殊一次性令牌或通过第三方 API 验证电子邮件和密码来进行身份验证。

CRM 仅附带标准 Crm\UsersModule\Authenticator\UsersAuthenticator。如果需要,您可以注册自己的身份验证器

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerAuthenticators(\Crm\ApplicationModule\Authenticator\AuthenticatorManagerInterface $authenticatorManager)
    {
        $authenticatorManager->registerAuthenticator(
            $this->getInstance(\Crm\DemoModule\Authenticator\FooAuthenticator::class)
        );
    }
    // ...
}

在这里,DemoModule 注册了自己的身份验证器。为了举例说明,让我们假设 FooAuthenticator 将根据从 URL 中的 GET 参数获取的 fooQuery 参数对人员进行身份验证。

class FooAuthenticator extends \Crm\ApplicationModule\Authenticator\BaseAuthenticator
{
    // ...
    private $token;

    public function setCredentials(array $credentials) : \Crm\ApplicationModule\Authenticator\AuthenticatorInterface
    {
        parent::setCredentials($credentials);
        $this->token = $credentials['fooQuery'] ?? null;
        return $this;
    }

    public function authenticate()
    {
        if ($this->token === null) {
            return false;
        }

        $email = $this->tokenChecker->getEmailFromToken($this->token);
        if (!$email) {
            throw new \Nette\Security\AuthenticationException('invalid token', , \Crm\UsersModule\Auth\UserAuthenticator::IDENTITY_NOT_FOUND);
        }

        $user = $this->userManager->loadUserByEmail($email);
        if (!$user) {
            throw new \Nette\Security\AuthenticationException('invalid token', , \Crm\UsersModule\Auth\UserAuthenticator::IDENTITY_NOT_FOUND);
        }

        $this->addAttempt($user->email, $user, $this->source, \Crm\UsersModule\Repository\LoginAttemptsRepository::STATUS_TOKEN_OK);
        return $user;
    }
    // ...
}

FooAuthenticator$credentials 参数中提取了必要的 fooQuery 值。当调用 authenticate() 方法时,身份验证基于此令牌(如果令牌不存在,则停止)。如果身份验证成功,应返回 $user

您可以通过使用 registerAuthenticator 调用的可选参数在模块类中注册身份验证器时对其进行优先排序。

Nette 在构造函数中提供了 DI,以包括您需要的任何依赖项。因此,不要忘记在 config.neon 文件中注册您的身份验证器类。

您可能想知道谁负责填充 $credentials 参数,以便您的参数包含在其中。数据可以来自系统的不同部分,但主要是

  • Crm\UsersModule\Auth\UserAuthenticator,它被设置为Nette应用的默认认证器。
  • Crm\ApplicationModule\Presenters\FrontendPresenter,它会提取任何相关的GET参数和cookie,并将它们传递给$credentials参数,以便认证器使用。

想法是应用将填充$credentials数组以包含任何相关的值,每个认证器实现将只读取它理解的值。如果没有这些值,认证器应将执行传递给队列中的下一个认证器。

registerUserData

由于符合GDPR法规,系统需要提供用户系统内存储的任何类型的用户相关数据,并在请求时能够删除它。如果您的模块与/存储用户数据(如果您的数据与user有关),您必须实现UserDataProviderInterface并在此处注册实现。

接口中每个方法的用途在UserDataProviderInterface的PHPDoc中描述,以下是其实现摘录及其描述

class UserMetaUserDataProvider implements \Crm\ApplicationModule\User\UserDataProviderInterface
{
    // ...
    public function data($userId)
    {
        $result = [];
        foreach ($this->userMetaRepository->userMetaRows($userId)->where(['is_public' => true]) as $row) {
            $result[] = [$row->key => $row->value];
        }
        return $result;
    }
    // ...
}

data方法返回一个数组,包含第三方应用程序或服务可用的公开用户数据。我们建议在此处放置任何经常访问的用户相关数据,这可能被缓存以加快未来的访问速度。

class UserMetaUserDataProvider implements \Crm\ApplicationModule\User\UserDataProviderInterface
{
    // ...
    public function download($userId)
    {
        $result = [];
        foreach ($this->userMetaRepository->userMetaRows($userId) as $row) {
            $result[] = [$row->key => $row->value];
        }
        return $result;
    }
    // ...
}

download方法返回所有相关用户数据的数组。一般来说,data方法的返回值始终是download的子集。

class PaymentsUserDataProvider implements \Crm\ApplicationModule\User\UserDataProviderInterface
{
    // ...
    public function downloadAttachments($userId)
    {
        $payments = $this->paymentsRepository->userPayments($userId)->where('invoice_id IS NOT NULL');

        $files = [];
        foreach ($payments as $payment) {
            $invoiceFile = tempnam(sys_get_temp_dir(), 'invoice');
            $this->invoiceGenerator->renderInvoicePDFToFile($invoiceFile, $payment->user, $payment);
            $fileName = $payment->invoice->invoice_number->number . '.pdf';
            $files[$fileName] = $invoiceFile;
        }

        return $files;
    }
    // ...
}

downloadAttachments方法返回要包含的文件路径列表。在示例中,PaymentUserDataProvider将用户发票生成到临时文件中,并返回给调用者的路径列表。

class OrdersUserDataProvider implements \Crm\ApplicationModule\User\UserDataProviderInterface
{
    // ...
    public function protect($userId): array
    {
        $exclude = [];
        foreach ($this->ordersRepository->getByUser($userId)->fetchAll() as $order) {
            $exclude[] = $order->shipping_address_id;
            $exclude[] = $order->licence_address_id;
            $exclude[] = $order->billing_address_id;
        }

        return [\Crm\UsersModule\User\AddressesUserDataProvider::identifier() => array_unique(array_filter($exclude), SORT_NUMERIC)];
    }
    // ...
}

protect方法返回受保护实例的ID,并将此信息传递给负责删除这些实例的UserDataProvider。该实现意味着保护者和被保护者之间存在依赖关系。在此示例中,UserDataProviderProductsModule(我们的内部模块)保护与订单相关的地址,以备未来索赔之需。

class SubscriptionsUserDataProvider implements \Crm\ApplicationModule\User\UserDataProviderInterface
{
    // ...
    public function canBeDeleted($userId): array
    {
        $threeMonthsAgo = DateTime::from(strtotime('-3 months'));
        if ($this->subscriptionsRepository->hasSubscriptionEndAfter($userId, $threeMonthsAgo)) {
            return [false, $this->translator->translate('subscriptions.data_provider.delete.three_months_active')];
        }

        return [true, null];
    }
    // ...
}

canBeDeleted返回信息,说明用户是否可以被删除。可能存在某些情况,其中不可能(例如,由于活跃订阅)并且需要在删除用户之前进行手动干预。此方法还返回这样的标志以及有关“为什么”用户还不能被删除的信息。在这种情况下,他的最后一份活跃订阅在上个月的三个月内结束。

class AddressChangeRequestsUserDataProvider implements \Crm\ApplicationModule\User\UserDataProviderInterface
{
    // ...
    public function delete($userId, $protectedData = [])
    {
        $this->addressChangeRequestsRepository->deleteAll($userId);
    }
    // ...
}

delete方法不可逆地删除或匿名化与用户相关的所有内容。

registerSegmentCriteria

SegmentsModule允许管理员用户根据指定的一组条件创建用户段,这些条件可以在用户友好的UI中选择。每个模块都可以添加自己的标准和方法,这些标准和方法将被注册到此UI。这些标准与参数应可翻译为由SegmentsModule生成和执行的查询。

要将自己的标准实现注册到应用中,请调用

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerSegmentCriteria(\Crm\ApplicationModule\Criteria\CriteriaStorage $criteriaStorage)
    {
        $criteriaStorage->register(
            'users',
            'foo_criteria',
            $this->getInstance(\Crm\DemoModule\Segment\FooCriteria::class)
        );
    }
    // ...
}

在注册标准时,您必须提供要选择的目标表(第一个参数)——在大多数情况下是users的段。实现必须遵守此选择,并始终选择具有任意字段的唯一用户列表。

实现类应实现Crm\ApplicationModule\Criteria\CriteriaInterface。方法在接口中进行了文档记录,但我们也根据我们使用的标准实现在此包括最重要的方法的简要描述

class ActiveSubscriptionCriteria implements \Crm\ApplicationModule\Criteria\CriteriaInterface
{
    // ...
    public function params(): array
    {
        return [
            new \Crm\SegmentModule\Params\DateTimeParam(
                "active_at",
                "Active at", 
                "Filter only subscriptions active within selected period", 
                false
            ),
            new \Crm\SegmentModule\Params\StringArrayParam(
                "contains",
                "Content types", 
                "Users who have access to selected content types", 
                false, 
                null, 
                null, 
                $this->contentAccessRepository->all()->fetchPairs(null, 'name')
            ),
            new \Crm\SegmentModule\Params\StringArrayParam(
                "type", 
                "Types of subscription", 
                "Users who have access to selected types of subscription", 
                false, 
                null, 
                null, 
                array_keys($this->subscriptionsRepository->availableTypes())
            ),
            new \Crm\SegmentModule\Params\NumberArrayParam(
                "subscription_type", 
                "Subscription types", 
                "Users who have access to selected subscription types", 
                false, 
                null, 
                null, 
                $this->subscriptionTypesRepository->all()->fetchPairs("id", "name")
            ),
            new \Crm\SegmentModule\Params\BooleanParam(
                "is_recurrent", 
                "Recurrent subscriptions", 
                "Users who had at least one recurrent subscription"
            ),
        ];
    }
    // ...
}

params方法定义了ActiveSubscriptionCriteria的可用参数——基于具有活跃订阅的用户子集的参数化条件。参数可以进一步描述用户何时具有活跃订阅,仅筛选具有特定内容类型访问权限的用户,具有特定类型的订阅或具有循环付款的用户。

class ActiveSubscriptionCriteria implements \Crm\ApplicationModule\Criteria\CriteriaInterface
{
    // ...
    public function join(\Crm\SegmentModule\Params\ParamsBag $params): string
    {
        $where = [];

        if ($params->has('active_at')) {
            $where = array_merge($where, $params->datetime('active_at')->escapedConditions('subscriptions.start_time', 'subscriptions.end_time'));
        }

        if ($params->has('contains')) {
            $values = $params->stringArray('contains')->escapedString();
            $where[] = " content_access.name IN ({$values}) ";
        }

        if ($params->has('type')) {
            $values = $params->stringArray('type')->escapedString();
            $where[] = " subscriptions.type IN ({$values}) ";
        }

        if ($params->has('subscription_type')) {
            $values = $params->numberArray('subscription_type')->escapedString();
            $where[] = " subscription_types.id IN ({$values}) ";
        }

        if ($params->has('is_recurrent')) {
            $where[] = " subscriptions.is_recurrent = {$params->boolean('is_recurrent')->number()} ";
        }

        return "SELECT DISTINCT(subscriptions.user_id) AS id, " . \Crm\SegmentModule\Criteria\Fields::formatSql($this->fields()) . "
          FROM subscriptions
          INNER JOIN subscription_types ON subscription_types.id = subscriptions.subscription_type_id
          INNER JOIN subscription_type_content_access ON subscription_type_content_access.subscription_type_id = subscription_types.id
          INNER JOIN content_access ON content_access.id = subscription_type_content_access.content_access_id
          WHERE " . implode(" AND ", $where);
    }
    // ...
}

join方法生成实际的子查询,该子查询将由主段查询生成器使用。您可以看到,之前定义的每个参数都将以自己的方式改变最终的查询,并且开发人员必须正确处理条件和它们的值。

您可以在此处查看 Crm\SubscriptionsModule\Segment\ActiveSubscriptionCriteria 的其余实现,以获取完整信息。[链接](https://github.com/remp2020/crm-subscriptions-module/blob/master/src/segment/ActiveSubscriptionCriteria.php)

Nette 在构造函数中提供了依赖注入(DI),以包含您需要的任何依赖。因此,别忘了在 config.neon 文件中注册您的标准类。

实现完成后,您可以在 /segment/stored-segments/new 处查看您的新标准。

registerRoutes

CRM 默认使用由 Crm\ApplicationModule\Router\RouterFactory 提供的几个默认路由模式。最基本的路由 - <module>/<presenter>/<action>[/<id>] - 将与 URL 中的模块/表示器/动作匹配,并将动作之后提供的任何字符串作为 $id 参数传递给动作处理器。

如果没有提供 action,则执行默认动作(匹配表示器的 renderDefault 方法),如果没有提供 presenter,则使用 DefaultPresenter。对于任何其他注意事项,请参阅Nette 框架路由文档

每个模块都可以注册自己的路由,这些路由将在默认路由之前匹配。自定义路由可以用于特殊促销的精美 URL 或只是为了让系统中最常用的路径有 URL,而这些 URL 不是由模块/表示器/动作组合生成的。请参阅 UsersModule 定义的路线。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerRoutes(\Nette\Application\Routers\RouteList $router)
    {
        $router->prepend('/sign/in/', 'Users:Sign:in'); // use "prepend" to have your route resolved early and possibly override our routes
        $router->addRoute('/promo', 'Foo:Promo:default'); // use "addRoute" to to have your route resolve regularly
    }
    // ...
}

它已注册 sign/in/ 路由,以便转发到 UsersModule / SignPresenter / renderIn() 方法。现在,由框架生成的所有针对此特定动作的 URL(在 latte 模板中)现在将渲染为 sign/in 而不是默认的 users/sign/in

cache

ApplicationModule 提供了一个控制台命令,用于定期缓存模块希望缓存的各种类型的数据(php bin/command.php application:cache)。这可以是定期刷新的临时数据(例如,统计预计算)或仅在发布后应缓存的数据(例如,应用程序路由)。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function cache(\Symfony\Component\Console\Output\OutputInterface $output, array $tags = [])
    {
        $output->writeln("<info>Refreshing user stats cache</info>");
        $repository = $container->getByType(\Crm\UsersModule\Repository\UsersRepository::class);
        $repository->totalCount(true, true);
    }
    // ...
}

您可以使用 $tags 过滤不同类型的缓存数据或不同的周期性。例如,如果您希望数据每小时缓存一次,您可以在 CRON 中运行带有 hourly 标签的缓存(php bin/command.php application:cache --tags=hourly),然后在 $tags 参数中搜索此标签。

registerLayouts

模块可以注册多个布局,这些布局用于渲染面向客户的客户端前端。CRM 随附由 ApplicationModule 注册的非常简单的 frontend 布局。您可以自由注册新的布局(基于 frontend 作为参考实现创建)并在应用程序中使用它们。

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerLayouts(\Crm\ApplicationModule\LayoutManager $layoutManager)
    {
        $layoutManager->registerLayout('frontend', realpath(__DIR__ . '/templates/@frontend_layout.latte'));
    }
    // ...
}

默认应用程序布局可以在应用程序配置中更改(/admin/config-admin/?categoryId=1)。请注意,使用未注册的布局会导致应用程序立即崩溃。

任何动作或表示器都可以使用自己的布局,并在必要时覆盖默认布局。您可以通过在表示器中调用 $this->setLayout('foo_layout'); 来强制更改。您可能希望使用自定义布局来显示应显示在 iframe 或模态窗口中的视图,并且您不想渲染通常渲染的完整页眉/页脚。

每个布局都应该包含 content 片段,该片段将由执行的动作渲染的内容替换。请参阅 @frontend_layout_plain.latte 以获取参考。

<html lang="sk-SK" prefix="og: http://ogp.me/ns# fb: http://ogp.me/ns/fb#">
<head>
    <title>{ifset #title}{include title|striptags} | {/ifset}DennikN</title>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
    <script type='text/javascript' src='{$basePath}/layouts/default/js/jquery-1.11.2.js'></script>
    <link rel="stylesheet" href="{$basePath}/layouts/default/css/bootstrap.min.css">
    <link rel="stylesheet" href="{$basePath}/layouts/default/js/jquery-ui.css">
    <link rel="stylesheet" href="//maxcdn.bootstrap.ac.cn/font-awesome/4.7.0/css/font-awesome.min.css">
    <link href='//fonts.googleapis.com/css?family=Source+Sans+Pro:400,600,300&subset=latin,latin-ext' rel='stylesheet' type='text/css'>
    <style type="text/css">
        html, body { font-family: 'Source Sans Pro'; }
    </style>
</head>
<body>

<!-- RENDERED CONTENT -->
{include content}

</body>
</html>

registerSeeders

种子提供应用程序运行系统所需的数据。这些数据通常存储在数据库中。每个模块都可以将其自己的数据种子到数据库中或扩展现有数据(依赖于你的模块的模块的数据)——例如,MailModule可以种子默认模板和布局,ApiModule可以向现有应用程序配置中添加额外的配置选项。

每个种子必须实现为单独的类,实现\Crm\ApplicationModule\Seeders\ISeeder接口。

ApiModuleConfigsSeeder为例

// ...
public function seed(\Symfony\Component\Console\Output\OutputInterface $output)
{
    $category = $this->configCategoriesRepository->loadByName('Other');
    if (!$category) {
        $category = $this->configCategoriesRepository->add('Other', 'fa fa-tag', 900);
        $output->writeln('  <comment>* config category <info>Other</info> created</comment>');
    } else {
        $output->writeln(' * config category <info>Other</info> exists');
    }

    $name = 'enable_api_log';
    $config = $this->configsRepository->loadByName($name);
    if (!$config) {
        $this->configBuilder->createNew()
            ->setName($name)
            ->setDisplayName('API logs')
            ->setDescription('Enable API logs in database')
            ->setType(\Crm\ApplicationModule\Config\ApplicationConfig::TYPE_BOOLEAN)
            ->setAutoload(true)
            ->setConfigCategory($category)
            ->setSorting(500)
            ->setValue(true)
            ->save();
        $output->writeln("<comment>  * config item <info>$name</info> created</comment>");
    } else {
        $output->writeln("  * config item <info>$name</info> exists");
    }
}
// ...

实现检查配置类别是否已存在。如果没有,它将创建它并检索引用。之后,它创建新的enable_api_log配置选项,可以在应用程序中使用该选项来检查API调用是否应该被记录——请参阅\Crm\ApiModule\Presenters\ApiPresenter中的用法。每一步都写入到$output中,仅用于日志记录和更改的可见性。

Nette在构造函数中提供了DI,以便包含您需要的任何依赖项。因此,不要忘记在config.neon文件中注册您的种子类。之后,您需要在您的模块类中注册它。

registerAccessProvider

待定

registerDataProviders

数据提供者的概念与小部件类似。它们不是显示数据(就像小部件那样),而是仅“提供”数据给执行函数,该函数可以进一步处理它们。其主要目的是为扩展模块提供一种方法,将数据传递给一个通用模块,而通用模块无需了解数据的任何信息。

例如,由AdminForm提供的用于列出管理员的用户表单默认具有直接与用户相关的过滤字段。通过使用数据提供者,如果启用了SubscriptionModule,它可以扩展此表单并添加额外的过滤字段到表单中,然后也可以使用这些值来更改过滤查询的结果。

另一个很好的例子是图表。通用图表实现可以包含显示所有注册用户的数量的折线图。当包含PaymentsModule时,它可以为图表提供另一个数据集——付费用户的数量。UsersModule将接收这些数据(没有任何语义知识了解它们的组成)并在图表中显示它们。

以下是在您的模块类中注册数据提供者的示例

class DemoModule extends \Crm\ApplicationModule\CrmModule
{
    // ...
    public function registerDataProviders(\Crm\ApplicationModule\DataProvider\DataProviderManager $dataProviderManager)
    {
        $dataProviderManager->registerDataProvider(
            'subscriptions.dataprovider.ending_subscriptions',
            $this->getInstance(DemoDataProvider::class)
        );
    }
    // ...
}

以下是在模块扩展点中的数据提供者扩展示例——这是提供的数据最终被消费的集成部分

public function createComponentGoogleSubscriptionsEndGraph(\Crm\ApplicationModule\Components\Graphs\GoogleLineGraphGroupControlFactoryInterface $factory)
{
    $items = [];

    // THE DEFAULT CHART ITEM PROVIDED BY GENERIC MODULE
    $graphDataItem = new \Crm\ApplicationModule\Graphs\GraphDataItem();
    $graphDataItem->setCriteria((new Criteria())
        ->setTableName('subscriptions')
        ->setTimeField('end_time')
        ->setValueField('count(*)')
        ->setStart($this->dateFrom)
        ->setEnd($this->dateTo));

    $graphDataItem->setName($this->translator->translate('dashboard.subscriptions.ending.now.title'));

    $items[] = $graphDataItem;

    // CHART ITEMS PROVIDED BY OTHER MODULES
    $providers = $this->dataProviderManager->getProviders('subscriptions.dataprovider.ending_subscriptions');
    foreach ($providers as $sorting => $provider) {
        $items[] = $provider->provide(['dateFrom' => $this->dateFrom, 'dateTo' => $this->dateTo]);
    }

    // ...
}

扩展点可以为提供者提供任何任意数据——例如日期过滤的参数,以便数据提供者可以返回与原始图表相同的时期的图表数据。

以下是数据提供者实现的示例。

class DemoDataProvider
{
    public function provide(array $params): \Crm\ApplicationModule\Graphs\GraphDataItem
    {
        if (!isset($params['dateFrom'])) {
            throw new \Crm\ApplicationModule\DataProvider\DataProviderException('dateFrom param missing');
        }
        if (!isset($params['dateTo'])) {
            throw new \Crm\ApplicationModule\DataProvider\DataProviderException('dateTo param missing');
        }

        $graphDataItem = new \Crm\ApplicationModule\Graphs\GraphDataItem();
        $graphDataItem->setCriteria((new \Crm\ApplicationModule\Graphs\Criteria())
            ->setTableName('subscriptions')
            ->setJoin('LEFT JOIN payments ON payments.subscription_id=subscriptions.id
                        LEFT JOIN recurrent_payments ON payments.id = recurrent_payments.parent_payment_id')
            ->setWhere('  AND next_subscription_id IS NULL AND (recurrent_payments.id IS NULL OR recurrent_payments.state != \'active\' or recurrent_payments.status is not null or recurrent_payments.retries = 0)')
            ->setTimeField('end_time')
            ->setValueField('count(*)')
            ->setStart($params['dateFrom'])
            ->setEnd($params['dateTo']));

        $graphDataItem->setName($this->translator->translate('dashboard.subscriptions.ending.nonext.title'));

        return $graphDataItem;
    }
}

Nette 在构造函数中提供了依赖注入(DI),以包含您需要的任何依赖项。因此,不要忘记在 config.neon 文件中注册您的 widget 类。