> ## Documentation Index
> Fetch the complete documentation index at: https://docs.livingsecurity.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Power BI

> Подключите Power BI к Public API Living Security с помощью Power Query, постройте модель «звезда» на /v1 и выберите операционные или отчётные наборы данных для фишинга.

Это руководство — сквозной пример чтения `/v1` в Power BI. Оно охватывает
шаблон подключения на Power Query (M), семантическую модель по схеме «звезда»,
построенную на `/v1/activities`, инкрементальное обновление, а также случаи, когда следует использовать
предварительные наборы данных **reporting** для получения результатов фишинга по отдельным сотрудникам.

В примерах используется производственный хост для США
(`https://api.us-east-1.livingsecurity.ai/v1`). Для организации из ЕС используйте
`https://api.eu-west-1.livingsecurity.ai/v1` в `Fn_FetchAll` и
`Fn_GetJson`, а затем привяжите учётные данные источника данных к этому хосту. См.
[Regions](/ru/developers/v1/regions).

В этом руководстве используются сохранённые учётные данные Basic с ключом API. Клиент
OAuth не требуется. Варианты учётных данных описаны в разделе [Authentication](/ru/developers/v1/authentication),
а сведения о доступе и настройке — в [Developer Hub](/ru/developers/developer-hub/overview).

## Для чего предназначен этот пример

Используйте его, когда необходимо:

* Загрузить списочные конечные точки `/v1` в набор данных Power BI с корректной
  постраничной навигацией на основе курсора и повторными попытками при превышении лимита запросов.
* Построить семантическую модель, в которой меры («активные сущности», «действия на
  одну активную сущность») являются многомерными, а не подсчитываются по плоской таблице.
* Перевести плановое обновление Power BI с полного прохода по всей истории на
  небольшое ежедневное окно с помощью инкрементального обновления.
* Выбрать подходящую детализацию данных о фишинге: итоги по кампаниям (операционные) или
  результаты по отдельным сотрудникам и типизированные события (предварительная версия reporting).

## Предварительные требования

* Ключ API с разрешениями, необходимыми для ваших запросов:
  * Операционный пример: `activity:read`, `entity:read` и
    `simulation:read`.
  * Данные о фишинге по отдельным сотрудникам через наборы данных reporting: `reporting:read`.
    См. [API keys](/ru/developers/developer-hub/api-keys).
* Power BI Desktop или рабочая область Power BI / Fabric, в которой можно создать
  семантическую модель и настроить расписание обновления.

## Выбор подходящей детализации

`/v1` предоставляет две связанные области данных о фишинге. Они не взаимозаменяемы.

| Потребность | Использовать | Разрешение | Детализация |
| - | - | - | - |
| Итоги воронки кампании | `GET /v1/simulations/{id}/results` | `simulation:read` | Одна строка на симуляцию (доставлено, открыто, выполнен переход, …) |
| Результаты по отдельным сотрудникам (операционные) | `GET /v1/simulation_results` | `simulation:read` | Одна строка на пару «сущность × симуляция» |
| Результаты по отдельным сотрудникам (BI / отчётность) | `GET /v1/reporting/simulation_results` | `reporting:read` | Та же детализация, имена полей для BI (`entity_id`, `simulation_id`), водяной знак `updated_after` |
| Типизированные факты взаимодействий | `GET /v1/reporting/simulation_events` | `reporting:read` | Один факт на взаимодействие (`simulation.link_clicked`, …), `occurred_after` |
| Текущее измерение «Сотрудники» | `GET /v1/reporting/entities` | `reporting:read` | Одна текущая строка на сущность. Соединение по `reporting.simulation_results.entity_id = reporting.entities.id` |
| Текущие версии сущностей | `GET /v1/reporting/entity_versions` | `reporting:read` | Один текущий открытый интервал на сущность. Таблицы истории версий не существует |
| Текущее членство в когортах | `GET /v1/reporting/cohort_memberships` | `reporting:read` | Один текущий факт на `(cohort_id, entity_id)`. Таблицы истории членства не существует |
| Связующая ось для всех типов активности | `GET /v1/activities` | `activity:read` | Одно событие на активность, вложенное `participants[]` |

Приведённые ниже примеры запросов Power BI обращаются к **операционным** маршрутам
(`/v1/simulations`, `/v1/simulations/{id}/results`, `/v1/activities`,
`/v1/entities`). Для отчётности по фишингу на уровне отдельных сотрудников предпочтительно использовать
семейство предварительных маршрутов **reporting**, описанное ниже. Ключ, которому предоставлено только `reporting:read`, не может
вызывать операционный `/v1/simulation_results` (API возвращает `403`).

### Шкала сложности

Сложность симуляции — целое число от 1 до 5 в свойствах `/v1/simulations` (`difficulty_level`),
`/v1/simulation_results` (`difficulty_level`), `/v1/activities`
(`difficulty_tier`) и `/v1/reporting/simulation_events` (`difficulty_tier`).
`1` — самый лёгкий уровень, `5` — самый сложный. У адаптивных и прогрессивных симуляций нет
единого значения на уровне кампании, поэтому `/v1/simulations.difficulty_level` равно `null`,
при этом `difficulty_level` для отдельного получателя в результатах всё равно может быть задано. Для сопоставления с
прежними экспортами low/medium/high считайте `1–2` низким, `3` средним, а `4–5` —
высоким.

<Warning>
  Не создавайте новые интеграции для BI на основе недокументированных сетевых вызовов Панели управления
  или устаревшего кураторского обзора Public API `/api/v1`. Выполняйте аутентификацию на
  региональном хосте `/v1` вашей организации с помощью ключа API из Developer Hub. См.
  [Regions](/ru/developers/v1/regions).
</Warning>

## Шаблон подключения (Power Query)

В основе каждого запроса в примере лежат четыре правила. Каждое из них продиктовано
службой Power BI, а не стилистическими предпочтениями.

<AccordionGroup>
  <Accordion title="Сохраняйте базовый URL Web.Contents статическим и завершайте его на /v1">
    Всё динамическое поместите в `RelativePath` и `Query`. Первый аргумент
    `Web.Contents` должен быть строковым литералом, заканчивающимся на корне
    регионального `/v1` вашей организации:

    * США: `https://api.us-east-1.livingsecurity.ai/v1`
    * ЕС: `https://api.eu-west-1.livingsecurity.ai/v1`

    Затем вызывающий код передаёт только ресурс (`"simulations"`, а не
    `"v1/simulations"`). Не вставляйте хост для США для организации из ЕС.

    Если первый аргумент формируется конкатенацией строк, служба Power BI
    не может статически определить источник данных, и плановое обновление завершается
    ошибкой «dynamic data source», хотя в Power BI Desktop запрос работает.
  </Accordion>

  <Accordion title="Передавайте ключ API как сохранённые учётные данные Basic, а не в тексте запроса">
    Когда Power BI запрашивает учётные данные для источника данных `/v1`, выберите
    **Basic** и вставьте ключ API в поле **username**. В поле **Пароль** введите заполнитель,
    например `x`. Служба Power BI отклоняет пустой
    пароль, хотя API это поле игнорирует. Вариант с ключом в качестве пароля и заполнителем в качестве имени пользователя также работает.

    Не добавляйте заголовок `Authorization` в M. Он работает на вашем компьютере, но затем
    встраивает ключ в файл запроса и конфликтует с плановым обновлением.
  </Accordion>

  <Accordion title="Позвольте проверке подключения в диалоговом окне учётных данных выполниться">
    `GET /v1` требует аутентификации: он возвращает `200` при действительном ключе и `401`
    без него. Укажите в качестве источника данных `…/v1` и **не** пропускайте проверку —
    корректный ключ сохраняется, а отозванный или неверно сформированный сразу вызывает ошибку.

    В прежних примерах в качестве источника данных использовался голый хост, а проверку предлагалось пропустить,
    так как `GET /` всегда возвращает `404`. Перенастройте такие модели на `…/v1`.
  </Accordion>

  <Accordion title="Явно задавайте limit=500 в каждом списочном запросе">
    Размер страницы по умолчанию для каждой списочной конечной точки `/v1` равен 25. Если оставить значение по умолчанию,
    число запросов для тех же данных многократно возрастёт. Значения запроса должны быть текстовыми —
    `limit = "500"` в M, а не `limit = 500`.
  </Accordion>
</AccordionGroup>

### Общая функция постраничной навигации

Каждый запрос в примере вызывает единую функцию `Fn_FetchAll`, которая проходит
по `next_page_token`, пока оно не станет `null` (см.
[pagination](/ru/developers/v1/pagination)). Её основные свойства:

* **Статический базовый URL `/v1`, динамические `RelativePath` и `Query`.** Необходимо для
  планового обновления и для проверки учётных данных.
* **`ManualStatusHandling = {429}` с ограниченным числом повторных попыток.** Функция считывает
  `Retry-After`, если Power Query его предоставляет, в противном случае ждёт 30 секунд и
  после 5 подряд полученных ответов `429` завершается с явной ошибкой, а не
  зацикливается до истечения времени обновления. Для `5xx` повторные попытки намеренно не
  выполняются — ошибка сервера должна быть видна, а не приводить к тихому формированию неполного
  набора данных.
* **`Timeout` 60 секунд.** Страницы обычно отвечают значительно быстрее чем за секунду, поэтому
  минута молчания означает, что запрос не выполняется.
* **`Binary.Buffer` перед `Json.Document`.** Без буферизации механизм mashup
  может повторно запросить поток ответа, если прочитает его более одного раза.
* **`baseQuery` передаётся без изменений на каждой итерации.** Токены страниц —
  непрозрачные подписанные курсоры, привязанные к организации, учётным данным, версии
  контракта и отпечатку фильтров и сортировки. Изменение фильтра
  в ходе прохода отклоняется как недействительный токен, а не перезапускается
  с первой страницы.

```m Fn_FetchAll.pq — base and call shape theme={null}
let
    // Literal /v1 root. Callers pass the resource alone ("simulations").
    // US: "https://api.us-east-1.livingsecurity.ai/v1"
    BaseUrl = "https://api.eu-west-1.livingsecurity.ai/v1",

    Fn_FetchAll = (relativePath as text, baseQuery as record) as list =>
        // Walk next_page_token until null. Full pagination + 429 retry lives
        // in examples/powerbi/queries/Fn_FetchAll.pq.
        ...
in
    Fn_FetchAll
```

### Типизированные запросы для каждой конечной точки

Каждый потребитель `Fn_FetchAll` следует одной схеме: вызвать конечную точку,
спроецировать фиксированный список столбцов, удалить дискриминатор `object` и
привести временные метки к типу `datetimezone`, чтобы значения дат в мерах не смещались
незаметно из-за часового пояса ёмкости обновления.

<CodeGroup>
  ```m Simulations.pq (GET /v1/simulations) theme={null}
  let
      Source = Fn_FetchAll("simulations", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          {
              "id", "object", "name", "description", "status", "vectors",
              "difficulty_level", "difficulty_mode",
              "started_at", "completed_at", "created_at", "updated_at"
          },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Flattened = Table.TransformColumns(
          Dropped,
          {
              {
                  "vectors",
                  each if _ = null then null
                       else Text.Combine(List.Transform(_, Text.From), ", "),
                  type text
              }
          }),

      Typed = Table.TransformColumnTypes(
          Flattened,
          {
              { "id",           type text },
              { "name",         type text },
              { "description",  type text },
              { "status",       type text },
              { "started_at",   type datetimezone },
              { "completed_at", type datetimezone },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```

  ```m Entities.pq (GET /v1/entities) theme={null}
  let
      Source = Fn_FetchAll("entities", [ limit = "500" ]),

      AsTable = Table.FromRecords(
          Source,
          { "id", "object", "type", "status", "display_name", "email",
            "created_at", "updated_at" },
          MissingField.UseNull),

      Dropped = Table.RemoveColumns(AsTable, { "object" }),

      Typed = Table.TransformColumnTypes(
          Dropped,
          {
              { "id",           type text },
              { "type",         type text },
              { "status",       type text },
              { "display_name", type text },
              { "email",        type text },
              { "created_at",   type datetimezone },
              { "updated_at",   type datetimezone }
          })
  in
      Typed
  ```
</CodeGroup>

<Tip>
  Явный список столбцов в `Table.FromRecords` сохраняет стабильность схемы, когда
  конечная точка возвращает ноль строк. Простой `Table.FromRecords({})` даёт
  таблицу без столбцов, и все последующие шаги завершаются ошибкой из-за отсутствующих полей.
</Tip>

## Excel

Тот же Power Query (M) работает в Get & Transform в Excel — это тот же
механизм, что и в Power BI Desktop. Используйте этот вариант, когда вам нужна таблица на листе
вместо семантической модели.

В Excel для Windows (Microsoft 365, Excel 2024, 2021, 2019 или 2016):

1. **Data → Get Data → From Other Sources → Blank Query.**
2. В Power Query Editor выберите **Главная страница → Расширенный редактор**. Сначала вставьте
   `Fn_FetchAll`, затем переименуйте этот запрос в `Fn_FetchAll` (Query
   Settings → Name или щёлкните правой кнопкой мыши в области Queries и выберите **Rename**).
3. Повторите для `Fn_GetJson`, а затем для каждого запроса отчёта.
4. **Главная страница → Close & Load → Close & Load To…** открывает диалоговое окно **Import Data**.
   Выберите **Only Create Connection** для `Fn_FetchAll` и
   `Fn_GetJson` (это функции, а не таблицы). Выберите **Table** для
   каждого запроса отчёта, который нужно разместить на листе.
5. При первом обновлении выберите **Basic**: ключ API в поле **User name**, а
   заполнитель, например `x`, в поле **Пароль**. Excel хранит одни учётные данные
   на URL источника данных; последующие запросы к тому же хосту `/v1` используют
   их повторно.

<Note>
  **Close & Load To…** может быть недоступна (затемнена) при первом создании запроса в
  книге. Используйте **Close & Load**, затем в Excel выберите **Data →
  Queries & Connections**, щёлкните запрос правой кнопкой мыши и выберите **Load To**.
  Корпорация Майкрософт документирует этот путь именно для такого случая.
</Note>

Отправить `Living-Security-Version` из Power Query для
источника с аутентификацией невозможно — это то же ограничение, что и в Power BI. Закрепите версию за
сервисной учётной записью или передайте `api_version` в записи запроса. См.
[Contract versions and stored credentials](#версии-контракта-и-сохранённые-учётные-данные).

Готовые запросы отчётов (сопоставление столбцов и отличия от экспорта
предыдущего поставщика):

<CardGroup cols={3}>
  <Card title="Per-person compliance" href="/ru/developers/v1/reports/per-person-compliance">
    Зачисления на обучение с детализацией «сотрудник × назначение».
  </Card>

  <Card title="Email phishing log" href="/ru/developers/v1/reports/email-phishing-log">
    Фишинговые взаимодействия на уровне событий за последние 12 месяцев.
  </Card>

  <Card title="Most susceptible users" href="/ru/developers/v1/reports/most-susceptible-users">
    Сводка по каждому сотруднику за 12 месяцев: переходы по ссылкам, ввод учётных данных и сообщения о фишинге.
  </Card>
</CardGroup>

## Предварительные наборы данных reporting (фишинг по отдельным сотрудникам)

Семейство предварительных наборов данных reporting предоставляет наборы данных о фишинге по отдельным сотрудникам.
Для него требуется `reporting:read` и доступ к отчётности для вашей организации.
Используйте приведённый ниже каталог наборов данных, чтобы изучить их схемы и политики данных.

Получите актуальный каталог:

```text theme={null}
GET /v1/reporting/datasets
GET /v1/reporting/datasets/simulation_results
GET /v1/reporting/datasets/simulation_events
GET /v1/reporting/datasets/entities
GET /v1/reporting/datasets/entity_versions
GET /v1/reporting/datasets/cohort_memberships
```

Каждый набор данных объявляет детализацию, первичный ключ, соединения, водяной знак, политику коррекции и
политику удаления.

### simulation\_results

* **Путь:** `GET /v1/reporting/simulation_results`
* **Детализация:** сущность × симуляция
* **Фильтры:** `simulation`, `entity`, `updated_after`, а также `limit` /
  `page_token`
* **Объект передачи:** `reporting.simulation_result`
* **Ключи соединения:** `entity_id`, `simulation_id`

```m ReportingSimulationResults.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_results",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id", "status",
            "targeted_at", "delivered_at", "opened_at", "clicked_at",
            "credential_submitted_at", "reported_at",
            "email_subject", "sender_email", "difficulty_level",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "status",        type text },
            { "targeted_at",   type datetimezone },
            { "updated_at",    type datetimezone }
        })
in
    Typed
```

Используйте `updated_after` как включающий нижний водяной знак при обновлении последних
партиций. Изменяемые измерения результатов пересчитываются на месте под тем же `id`.

### simulation\_events

* **Путь:** `GET /v1/reporting/simulation_events`
* **Детализация:** один типизированный факт на каждое зафиксированное взаимодействие
* **Фильтры:** `simulation`, `entity`, `event_type`, `occurred_after`, а также
  `limit` / `page_token`
* **Объект передачи:** `reporting.simulation_event`
* **Типы событий:** закрытый набор публичных токенов, например `simulation.delivered`,
  `simulation.opened`, `simulation.link_clicked`,
  `simulation.credentials_submitted`, `simulation.reported` — а не необработанные
  типы активности ClickHouse `phishing.simulation.*`
* **Устройство:** `device_type` — производный класс (среди наблюдаемых токенов —
  `desktop`, `mobile`, `tablet`, `unknown`). Необработанный `user_agent` отсутствует в
  `/v1`. Необработанный IP-адрес никогда не попадает в `/v1` (не сохраняется; только `ip_hash` с солью).

```m ReportingSimulationEvents.pq (sketch) theme={null}
let
    Source = Fn_FetchAll(
        "reporting/simulation_events",
        [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "simulation_id",
            "event_type", "occurred_at",
            "email_subject", "sender_email", "sender_domain", "difficulty_tier",
            "device_type",
            "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "entity_id",     type text },
            { "simulation_id", type text },
            { "event_type",    type text },
            { "occurred_at",   type datetimezone }
        })
in
    Typed
```

Используйте `occurred_after` для инкрементальной загрузки событий. Факты только добавляются;
поздно поступившие данные становятся новыми строками.

### entities

* **Путь:** `GET /v1/reporting/entities`
* **Детализация:** одна текущая строка на сущность
* **Фильтры:** `type`, `updated_after`, а также `limit` / `page_token`
* **Объект передачи:** `reporting.entity`
* **Ключ соединения:** `id` / `entity_id` (одно и то же значение)

Соединяйте факты фишинга с сотрудниками с помощью
`reporting.simulation_results.entity_id = reporting.entities.id`.
Операционный `GET /v1/entities` остаётся на `entity:read` и требует другого
разрешения.

```m ReportingEntities.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entities", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status",
            "display_name", "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",          type text },
            { "entity_id",   type text },
            { "type",        type text },
            { "status",      type text },
            { "display_name", type text },
            { "created_at",  type datetimezone },
            { "updated_at",  type datetimezone }
        })
in
    Typed
```

### entity\_versions и cohort\_memberships

Эти два набора данных имеют именованное исключение «только текущие данные»:

> Авторитетной таблицы истории версий или членства не существует. Этот набор данных отображает текущую строку как один открытый интервал (`valid_to` равно null, `is_current` равно true). `valid_from` — это `created_at`, а при его отсутствии `updated_at` для версий сущностей и время наблюдения запроса для членства в когортах. Закрытые исторические интервалы не создаются искусственно.

* **entity\_versions:** `GET /v1/reporting/entity_versions`. Те же фильтры, что и у
  entities. `valid_from` — это `created_at`, а при его отсутствии `updated_at`. Сущность,
  у которой обе временные метки равны null, пропускается.
* **cohort\_memberships:** `GET /v1/reporting/cohort_memberships`. Фильтры:
  `cohort`, `entity`, `updated_after`. `updated_at` — время последнего обновления когорты,
  благодаря чему инкрементальное обновление остаётся корректным. `valid_from` — время наблюдения
  запроса. Соедините `entity_id` с `reporting.entities.id`.

```m ReportingEntityVersions.pq theme={null}
let
    Source = Fn_FetchAll("reporting/entity_versions", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "entity_id", "type", "status", "display_name",
            "valid_from", "valid_to", "is_current",
            "created_at", "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical }
        })
in
    Typed
```

```m ReportingCohortMemberships.pq theme={null}
let
    Source = Fn_FetchAll("reporting/cohort_memberships", [ limit = "500" ]),

    AsTable = Table.FromRecords(
        Source,
        {
            "id", "object", "cohort_id", "entity_id",
            "valid_from", "valid_to", "is_current",
            "updated_at", "deleted_at"
        },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",         type text },
            { "cohort_id",  type text },
            { "entity_id",  type text },
            { "valid_from", type datetimezone },
            { "is_current", type logical },
            { "updated_at", type datetimezone }
        })
in
    Typed
```

## Семантическая модель «звезда» на /v1/activities

`/v1/activities` — это поток событий организации с временными метками. В нём нет
числовых мер — каждая величина в отчёте представляет собой количество строк, — что делает схему
«звезда» естественным выбором.

```text theme={null}
Dates (dimension)
      │  1
      ▼  ∗
  Activities (fact)   1  ∗  ActivityParticipants (bridge)   ∗  1   Entities (dimension)
```

* **`Activities`** — таблица фактов событий с истинной детализацией. Одна строка на активность.
  Участники здесь отбрасываются и воссоздаются в виде отдельной таблицы.
* **`ActivityParticipants`** существует потому, что участники на самом деле связаны
  отношением «один ко многим» и имеют роли, такие как `actor` и `target`. Разворачивание в плоскую структуру на
  `Activities` привело бы либо к дублированию строк активности (завышению каждого подсчёта),
  либо к потере дополнительных участников.
  Связь с `Activities` — единственная двунаправленная
  связь в модели, поэтому срез по сущности доходит до таблицы фактов.
* **`Entities`** — измерение, которое называет участников. Строки участников
  в активности содержат `entity_id` и роль, но не отображаемое имя.
* **`Dates`** создаётся в M (без обращений к API) и помечается как таблица дат
  модели. Именно это делает допустимой аналитику времени в DAX
  (`DATESINPERIOD`, `DATEADD`). Столбцы с названиями месяцев и дней недели объявляют
  `sortByColumn` на своих числовых двойниках, чтобы визуальные элементы не сортировали месяцы
  по алфавиту.

Все меры явные (в модели задано `discourageImplicitMeasures`): общее число
активностей, активные сущности, активности на одну активную сущность, дни с активностью
и 7-дневное скользящее среднее за день.

### Структура таблицы-моста

`ActivityParticipants` проходит по `/v1/activities` и разворачивает массив `participants`
в одну строку на участника:

```m ActivityParticipants.pq theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Selected = Table.SelectColumns(AsTable, { "id", "event_ts", "participants" }),
    Renamed  = Table.RenameColumns(Selected, { { "id", "activity_id" } }),
    Expanded = Table.ExpandListColumn(Renamed, "participants"),
    NonNull  = Table.SelectRows(Expanded, each [participants] <> null),
    Fields   = Table.ExpandRecordColumn(
        NonNull, "participants",
        { "role", "entity_id", "display_name" }),

    Typed = Table.TransformColumnTypes(
        Fields,
        {
            { "activity_id",  type text },
            { "event_ts",     type datetimezone },
            { "role",         type text },
            { "entity_id",    type text },
            { "display_name", type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd)
in
    Filtered
```

`ActivityParticipants` проходит по `/v1/activities` независимо, а не
ссылается на запрос `Activities`. В Power BI два запроса, загруженные как две
таблицы, при обновлении получают данные независимо. Их независимость
позволяет службе обновлять партиции обеих таблиц параллельно.

## Инкрементальное обновление

Обе таблицы с детализацией фактов (`Activities` и `ActivityParticipants`) объявляют
политику инкрементального обновления Power BI: хранить 5 лет годовых партиций и
повторно импортировать последние 14 дней при каждом обновлении.

Механизм основан на паре параметров DateTime, `RangeStart` и `RangeEnd`,
которые служба Power BI переопределяет для каждой партиции во время обновления. Каждый запрос фактов
подставляет эти параметры в фильтры API `start_date` и `end_date`,
поэтому объём запросов на уровне партиции пропорционален партиции,
а не всей истории. Запрос расширяет окно API на минуту
с каждой стороны и локально повторно применяет точную полуоткрытую границу `[RangeStart, RangeEnd)`,
поэтому корректность партиции не зависит от того, считает ли сервер
свои границы включающими или исключающими.

```m Activities.pq — the fold that makes incremental refresh work theme={null}
let
    WindowStart = DateTime.AddZone(RangeStart, 0),
    WindowEnd   = DateTime.AddZone(RangeEnd, 0),
    ToIso = (value as datetimezone) as text =>
        DateTimeZone.ToText(value,
            [ Format = "yyyy-MM-dd'T'HH:mm:ss'Z'", Culture = "en-US" ]),

    Source = Fn_FetchAll(
        "activities",
        [
            limit      = "100",
            start_date = ToIso(WindowStart - #duration(0, 0, 1, 0)),
            end_date   = ToIso(WindowEnd + #duration(0, 0, 1, 0))
        ]),

    AsTable = Table.FromRecords(
        Source,
        { "id", "object", "activity_type", "event_ts",
          "service_id", "participants" },
        MissingField.UseNull),

    Dropped = Table.RemoveColumns(AsTable, { "object", "participants" }),

    Typed = Table.TransformColumnTypes(
        Dropped,
        {
            { "id",            type text },
            { "activity_type", type text },
            { "event_ts",      type datetimezone },
            { "service_id",    type text }
        }),

    Filtered = Table.SelectRows(
        Typed,
        each [event_ts] >= WindowStart and [event_ts] < WindowEnd),

    WithDate = Table.AddColumn(
        Filtered, "event_date", each Date.From([event_ts]), type date)
in
    WithDate
```

Первое обновление после развёртывания применяет политику: выполняется полная загрузка истории,
это медленно. Каждое последующее обновление затрагивает только инкрементальное окно.

Измерено на наборе данных из \~158 000 активностей и \~2 300 сущностей:

| Обновление | Фактическое время |
| - | - |
| Первое (применение политики, полная загрузка истории) | \~31 мин |
| Каждое последующее | **\~15 с** |

<Warning>
  Классическая конечная точка REST для обновления Power BI (`POST .../refreshes` только с
  `notifyOption`) молча игнорирует политики обновления и каждый раз выполняет полный проход. Чтобы применить
  политику через REST API, используйте расширенное обновление: передайте в теле запроса
  `type`, `commitMode` и `applyRefreshPolicy`. Обновления,
  запущенные планировщиком службы Power BI, всегда применяют политики — эта
  особенность относится только к вызову REST.
</Warning>

<Note>
  14-дневное окно намеренно выбрано с запасом. Приём данных об активности не ограничивается
  добавлением только текущих событий: при заполнении истории коннекторами поступают события с прошлыми временными метками.
  Окно в 1–2 дня безвозвратно пропустило бы их.
</Note>

## Расширение окна обновления на стороне API

`GET /v1/activities` принимает фильтры `start_date` и `end_date`, в которые
подставляются параметры Power BI, фильтруя `event_ts` между границами.
`Entities` является измерением, поэтому в этом примере оно обновляется полностью каждый раз,
а не инкрементально.

Наборы данных reporting используют собственные водяные знаки (`updated_after`,
`occurred_after`) вместо окна дат активностей.

## Ограничения частоты запросов во время обновления

`/v1` применяет отдельные лимиты для каждого ключа и каждого IP-адреса (см.
[rate limits](/ru/developers/v1/rate-limits)). Учитывайте `429` + `Retry-After`; не
закладывайте в модель жёстко заданный лимит запросов. Важны два свойства
обновлений Power BI:

* **Проход по одной таблице выполняется последовательно.** Для каждого запроса страницы нужен токен
  предыдущей страницы, поэтому скорость обработки одной таблицы ограничена задержкой независимо от
  размера страницы.
* **Параллельные обновления таблиц накладываются друг на друга.** Именно одновременное обновление нескольких больших таблиц
  приводит к достижению предела, поэтому важен путь повторных попыток при ответе 429
  в `Fn_FetchAll`. Лучше уменьшить число параллельных таблиц, чем
  рассчитывать на неограниченный параллелизм.

Механизм повторных попыток проверен на реальном насыщенном ограничителе. Обновление,
которое выполнялось, пока параллельная нагрузка намеренно удерживала ключ на предельном уровне,
всё равно завершилось и дало те же количества строк, что и базовое обновление без конкуренции, —
повторные попытки возвращали полные страницы, без пропусков.

## Версии контракта и сохранённые учётные данные

`/v1` принимает заголовок запроса `Living-Security-Version`, чтобы закрепить опубликованную
версию контракта (см. [API quickstart](/ru/developers/v1/quickstart)). Power
Query не может отправлять этот заголовок, если источник данных использует сохранённые учётные данные, —
пользовательские заголовки допускают только анонимные источники данных. Его добавление приводит к сбою обновления
со следующей ошибкой:

```text theme={null}
Expression.Error: The 'Living-Security-Version' header is only supported when
connecting anonymously.
Microsoft.Data.Mashup.ErrorCode = 10348.
```

Храните ключ в сохранённых учётных данных Power BI и не добавляйте пользовательский заголовок.
Если в запросе явно не указана версия, API использует закреплённую
за сервисной учётной записью версию `contract_version` или последнюю опубликованную версию, если закрепления нет.
Прежде чем публиковать обновление, уточните версию контракта учётной записи у администратора API. Поддерживаемые версии
см. в [API changelog](/ru/developers/v1/changelog).

Если обновление начинает вести себя неожиданно, проверьте заголовок ответа API
`Living-Security-Version`, чтобы узнать, какой контракт
фактически обработал запрос.

## Обновление и проверка модели

Опубликуйте семантическую модель в рабочей области Power BI, привяжите её сохранённые
учётные данные к региональному источнику `/v1` и выполните обновление, прежде чем включать
расписание. Проверьте историю обновлений на наличие сбоев или неполного чтения.

Сверьте количества строк с независимым чтением через API с теми же фильтрами.
В [руководстве по постраничной навигации](/ru/developers/v1/pagination) приведены примеры на Python и cURL
для чтения полного набора результатов. Обновление, читающее только
первую страницу, может выглядеть успешным на небольшом наборе данных; сравните количества,
прежде чем полагаться на отчёт.

## Проектные решения модели

* **Итоги кампаний и результаты по отдельным сотрудникам имеют разную детализацию.**
  `SimulationResults` вызывает `GET /v1/simulations/{id}/results`
  (один GET на запуск). Для данных о фишинге по отдельным сотрудникам добавьте запросы для
  `GET /v1/reporting/simulation_results` и
  `GET /v1/reporting/simulation_events` (или операционного
  `GET /v1/simulation_results`, если у вас уже есть `simulation:read`).
* **`vectors` в `/v1/simulations` преобразуется в строку, соединённую запятыми.**
  Столбец типа «список» нельзя использовать в срезе как настоящее измерение. Если странице отчёта
  нужна фильтрация по вектору, добавьте таблицу-мост «симуляция — вектор».

## Дальнейшие шаги

<CardGroup cols={2}>
  <Card title="Developer Hub" href="/ru/developers/developer-hub/overview">
    Создайте сервисную учётную запись и ключ API для Power BI.
  </Card>

  <Card title="API quickstart" href="/ru/developers/v1/quickstart">
    Аутентификация запросов `/v1` с помощью Basic.
  </Card>

  <Card title="Pagination" href="/ru/developers/v1/pagination">
    Конверт списка, токены курсора и стабильность фильтров при проходе.
  </Card>

  <Card title="Rate limits" href="/ru/developers/v1/rate-limits">
    Лимиты для каждого ключа и каждого IP-адреса, контракт ответа 429 и рекомендации по повторным попыткам.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.