> ## 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

> Verbinden Sie Power BI mit der Living Security Public API über Power Query, erstellen Sie ein Sternschema-Modell auf /v1 und wählen Sie zwischen operativen und Reporting-Datasets für Phishing.

Dieser Leitfaden ist ein durchgängiges Beispiel für das Einlesen von `/v1` in Power BI. Er behandelt
das Konnektivitätsmuster in Power Query (M), ein Sternschema-Semantikmodell auf Basis von
`/v1/activities`, inkrementelle Aktualisierung und den Einsatz der **Reporting**-Preview-Datasets
für Phishing-Ergebnisse pro Person.

Die Beispiele verwenden den US-Produktionshost
(`https://api.us-east-1.livingsecurity.ai/v1`). Für eine EU-Organisation verwenden Sie
`https://api.eu-west-1.livingsecurity.ai/v1` sowohl in `Fn_FetchAll` als auch
in `Fn_GetJson` und binden dann die Datenquellen-Anmeldedaten an diesen Host. Siehe
[Regionen](/de/developers/v1/regions).

Dieser Leitfaden verwendet gespeicherte Basic-Anmeldedaten mit einem API-Schlüssel. Ein
OAuth-Client ist nicht erforderlich. Informationen zu den Optionen für Anmeldedaten finden Sie unter [Authentifizierung](/de/developers/v1/authentication),
Informationen zu Zugriff und Einrichtung im [Developer Hub](/de/developers/developer-hub/overview).

## Wofür dieses Beispiel gedacht ist

Verwenden Sie es, wenn Sie Folgendes benötigen:

* `/v1`-Listenendpunkte mit korrekter Cursor-Paginierung und einem Rate-Limit-Retry in ein Power-BI-Dataset laden.
* Ein Semantikmodell erstellen, in dem Kennzahlen („aktive Entitäten“, „Aktivitäten pro
  aktiver Entität“) dimensional berechnet werden, statt aus einer flachen Tabelle gezählt zu werden.
* Eine geplante Power-BI-Aktualisierung von einem vollständigen Durchlauf der gesamten Historie auf
  ein kleines tägliches Zeitfenster reduzieren, indem die inkrementelle Aktualisierung verwendet wird.
* Die passende Phishing-Granularität wählen: Kampagnensummen (operativ) oder
  Ergebnisse pro Person und typisierte Ereignisse (Reporting-Preview).

## Voraussetzungen

* Ein API-Schlüssel mit den Berechtigungen, die Ihre Abfragen benötigen:
  * Operatives Beispiel: `activity:read`, `entity:read` und
    `simulation:read`.
  * Phishing pro Person über Reporting-Datasets: `reporting:read`.
    Siehe [API-Schlüssel](/de/developers/developer-hub/api-keys).
* Power BI Desktop oder ein Power-BI-/Fabric-Arbeitsbereich, in dem Sie ein
  Semantikmodell erstellen und die Aktualisierung planen können.

## Die passende Granularität wählen

`/v1` stellt zwei verwandte Oberflächen für Phishing bereit. Sie sind nicht austauschbar.

| Bedarf | Verwendung | Berechtigung | Granularität |
| - | - | - | - |
| Summen des Kampagnentrichters | `GET /v1/simulations/{id}/results` | `simulation:read` | Eine Zeile pro Simulation (zugestellt, geöffnet, geklickt, …) |
| Ergebnisse pro Person (operativ) | `GET /v1/simulation_results` | `simulation:read` | Eine Zeile pro Entität × Simulation |
| Ergebnisse pro Person (BI / Reporting) | `GET /v1/reporting/simulation_results` | `reporting:read` | Gleiche Granularität, BI-Feldnamen (`entity_id`, `simulation_id`), `updated_after`-Wasserzeichen |
| Typisierte Interaktionsfakten | `GET /v1/reporting/simulation_events` | `reporting:read` | Ein Fakt pro Interaktion (`simulation.link_clicked`, …), `occurred_after` |
| Aktuelle Belegschaftsdimension | `GET /v1/reporting/entities` | `reporting:read` | Eine aktuelle Zeile pro Entität. Join über `reporting.simulation_results.entity_id = reporting.entities.id` |
| Aktuelle Entitätsversionen | `GET /v1/reporting/entity_versions` | `reporting:read` | Ein offenes aktuelles Intervall pro Entität. Es existiert keine Tabelle mit Versionshistorie |
| Aktuelle Kohortenmitgliedschaften | `GET /v1/reporting/cohort_memberships` | `reporting:read` | Ein aktueller Fakt pro `(cohort_id, entity_id)`. Es existiert keine Tabelle mit Mitgliedschaftshistorie |
| Join-Spine für alle Aktivitätstypen | `GET /v1/activities` | `activity:read` | Ein Ereignis pro Aktivität, verschachtelt `participants[]` |

Die nachfolgenden Power-BI-Beispielabfragen rufen die **operativen** Routen auf
(`/v1/simulations`, `/v1/simulations/{id}/results`, `/v1/activities`,
`/v1/entities`). Für das Phishing-Reporting pro Person bevorzugen Sie die
unten beschriebene **Reporting**-Preview-Familie. Ein Schlüssel, dem nur `reporting:read` gewährt wurde, kann
den operativen Endpunkt `/v1/simulation_results` nicht aufrufen (die API gibt `403` zurück).

### Schwierigkeitsskala

Der Schwierigkeitsgrad von Simulationen ist eine Ganzzahl von 1–5 bei `/v1/simulations` (`difficulty_level`),
`/v1/simulation_results` (`difficulty_level`), den `/v1/activities`-Eigenschaften
(`difficulty_tier`) und `/v1/reporting/simulation_events` (`difficulty_tier`).
`1` ist am einfachsten und `5` am schwierigsten. Adaptive und progressive Simulationen haben keinen
einzelnen Wert auf Kampagnenebene, daher ist `/v1/simulations.difficulty_level` `null`,
während der `difficulty_level` pro Empfänger in den Ergebnissen dennoch gesetzt sein kann. Um auf
ältere Low/Medium/High-Exporte abzubilden, behandeln Sie `1–2` als Low, `3` als Medium und `4–5` als
High.

<Warning>
  Erstellen Sie keine neuen BI-Integrationen auf Basis undokumentierter Netzwerkaufrufe des Dashboards
  oder der veralteten, kuratierten `/api/v1`-Public-API-Übersicht. Authentifizieren Sie sich am
  regionalen `/v1`-Host der Organisation mit einem API-Schlüssel aus dem Developer Hub. Siehe
  [Regionen](/de/developers/v1/regions).
</Warning>

## Konnektivitätsmuster (Power Query)

Vier Regeln liegen jeder Abfrage im Beispiel zugrunde. Jede davon wird vom
Power BI Service erzwungen und ist keine Frage des Stils.

<AccordionGroup>
  <Accordion title="Die Basis-URL von Web.Contents statisch halten und auf /v1 enden lassen">
    Legen Sie alles Dynamische in `RelativePath` und `Query` ab. Das erste Argument von
    `Web.Contents` muss ein Literal-String sein, der mit dem regionalen `/v1`-Stamm
    der Organisation endet:

    * US: `https://api.us-east-1.livingsecurity.ai/v1`
    * EU: `https://api.eu-west-1.livingsecurity.ai/v1`

    Aufrufer übergeben dann nur die Ressource (`"simulations"`, nicht
    `"v1/simulations"`). Fügen Sie für eine EU-Organisation keinen US-Host ein.

    Wird das erste Argument durch String-Verkettung gebildet, kann der Power BI Service
    die Datenquelle nicht statisch identifizieren, und die geplante Aktualisierung schlägt mit
    einem Fehler „dynamische Datenquelle“ fehl, obwohl die Abfrage in Power BI
    Desktop funktioniert.
  </Accordion>

  <Accordion title="Den API-Schlüssel als gespeicherte Basic-Anmeldedaten angeben, nicht im Abfragetext">
    Wenn Power BI für die `/v1`-Datenquelle nach Anmeldedaten fragt, wählen Sie
    **Basic** und fügen Sie den API-Schlüssel als **Benutzername** ein. Tragen Sie einen Platzhalter
    wie `x` in das Feld **Passwort** ein. Der Power BI Service lehnt ein leeres
    Passwort ab, obwohl die API dieses Feld ignoriert. Der Schlüssel als Passwort mit einem
    Platzhalter als Benutzernamen funktioniert ebenfalls.

    Fügen Sie in M keinen `Authorization`-Header hinzu. Das funktioniert auf Ihrem Desktop, schreibt dann aber
    den Schlüssel in die Abfragedatei und steht der geplanten Aktualisierung im Weg.
  </Accordion>

  <Accordion title="Den Verbindungstest des Anmeldedialogs ausführen lassen">
    `GET /v1` ist authentifiziert: Es gibt mit einem gültigen Schlüssel `200` zurück und ohne
    Schlüssel `401`. Richten Sie die Datenquelle auf `…/v1` aus und überspringen Sie den Test **nicht** –
    ein gültiger Schlüssel wird gespeichert, ein widerrufener oder fehlerhafter schlägt sofort fehl.

    Ältere Beispiele verwendeten den reinen Host als Datenquelle und wiesen an, den Test
    zu überspringen, weil `GET /` immer `404` zurückgibt. Richten Sie diese Modelle wieder auf `…/v1` aus.
  </Accordion>

  <Accordion title="limit=500 bei jeder Listenabfrage explizit setzen">
    Die Standardseitengröße bei jedem `/v1`-Listenendpunkt beträgt 25. Wird der Standard beibehalten,
    vervielfacht sich die Anzahl der Anfragen für dieselben Daten. Abfragewerte müssen Text sein –
    `limit = "500"` in M, nicht `limit = 500`.
  </Accordion>
</AccordionGroup>

### Gemeinsame Paginierungsfunktion

Jede Abfrage im Beispiel ruft eine einzelne `Fn_FetchAll` auf, die
`next_page_token` durchläuft, bis es `null` ist (siehe
[Paginierung](/de/developers/v1/pagination)). Ihre wichtigsten Eigenschaften:

* **Statische `/v1`-Basis-URL, dynamische `RelativePath` und `Query`.** Erforderlich für
  die geplante Aktualisierung und für den Anmeldedaten-Test.
* **`ManualStatusHandling = {429}` mit begrenztem Retry.** Die Funktion liest
  `Retry-After`, sofern Power Query es bereitstellt, wartet andernfalls 30 Sekunden und
  gibt nach 5 aufeinanderfolgenden `429`-Antworten mit einem expliziten Fehler auf, statt
  zu schleifen, bis die Aktualisierung in den Timeout läuft. `5xx` wird bewusst nicht
  wiederholt – ein Serverfehler soll sichtbar werden und nicht stillschweigend ein unvollständiges
  Dataset erzeugen.
* **60-Sekunden-`Timeout`.** Seiten antworten in der Regel deutlich unter einer Sekunde, daher
  bedeutet eine Minute Stille, dass die Anfrage tot ist.
* **`Binary.Buffer` vor `Json.Document`.** Ohne Pufferung
  fordert die Mashup-Engine den Antwortstrom unter Umständen erneut an, wenn sie ihn mehr als einmal liest.
* **`baseQuery` wird in jeder Iteration unverändert übergeben.** Seitentoken sind
  undurchsichtige, signierte Cursor, die an Organisation, Anmeldedaten, Vertragsversion
  sowie den Fingerabdruck von Filtern und Sortierung gebunden sind. Eine Änderung eines Filters
  während des Durchlaufs wird als ungültiges Token abgelehnt, anstatt auf
  Seite eins neu zu starten.

```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
```

### Typisierte Abfragen pro Endpunkt

Jeder Konsument von `Fn_FetchAll` folgt demselben Muster: den Endpunkt aufrufen,
eine feste Spaltenliste projizieren, den Diskriminator `object` entfernen und
Zeitstempel als `datetimezone` typisieren, damit Datumskennzahlen nicht unbemerkt um die
Zeitzone der Aktualisierungskapazität verschoben werden.

<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>
  Eine explizite Spaltenliste bei `Table.FromRecords` hält das Schema stabil,
  wenn ein Endpunkt null Zeilen zurückgibt. Ein bloßes `Table.FromRecords({})` liefert eine
  Tabelle ohne Spalten, und jeder spätere Schritt schlägt wegen der fehlenden Felder fehl.
</Tip>

## Excel

Dasselbe Power Query (M) läuft in Excel unter „Daten abrufen und transformieren“ – es ist dieselbe
Engine wie in Power BI Desktop. Verwenden Sie dies, wenn Sie eine Arbeitsblatttabelle
statt eines Semantikmodells wünschen.

In Excel für Windows (Microsoft 365, Excel 2024, 2021, 2019 oder 2016):

1. **Data → Get Data → From Other Sources → Blank Query.**
2. Wählen Sie im Power Query Editor **Home → Advanced Editor**. Fügen Sie zuerst
   `Fn_FetchAll` ein und benennen Sie diese Abfrage dann in `Fn_FetchAll` um (Query
   Settings → Name oder Rechtsklick im Bereich „Queries“ → **Rename**).
3. Wiederholen Sie dies für `Fn_GetJson` und anschließend für jede Berichtsabfrage.
4. **Home → Close & Load → Close & Load To…** öffnet den Dialog **Import Data**.
   Wählen Sie **Only Create Connection** für `Fn_FetchAll` und
   `Fn_GetJson` (es sind Funktionen, keine Tabellen). Wählen Sie **Table** für
   jede Berichtsabfrage, die Sie auf einem Arbeitsblatt haben möchten.
5. Wählen Sie bei der ersten Aktualisierung **Basic**: den API-Schlüssel als **User name**, einen
   Platzhalter wie `x` als **Password**. Excel speichert pro Datenquellen-URL
   einen Satz Anmeldedaten; spätere Abfragen, die denselben `/v1`-Host ansprechen, verwenden
   ihn erneut.

<Note>
  **Close & Load To…** kann beim ersten Erstellen einer Abfrage in einer
  Arbeitsmappe abgeblendet sein. Verwenden Sie **Close & Load**, wählen Sie dann in Excel **Data →
  Queries & Connections**, klicken Sie mit der rechten Maustaste auf die Abfrage und wählen Sie **Load To**.
  Dieser Weg wird von Microsoft für diesen Fall dokumentiert.
</Note>

Sie können `Living-Security-Version` aus Power Query bei einer
authentifizierten Quelle nicht senden – dieselbe Einschränkung wie in Power BI. Fixieren Sie das
Dienstkonto oder übergeben Sie `api_version` im Abfragedatensatz. Siehe
[Vertragsversionen und gespeicherte Anmeldedaten](#vertragsversionen-und-gespeicherte-anmeldedaten).

Fertige Berichtsabfragen (Spaltenzuordnungen und Abweichungen vom Export eines
früheren Anbieters):

<CardGroup cols={3}>
  <Card title="Compliance pro Person" href="/de/developers/v1/reports/per-person-compliance">
    Schulungsanmeldungen auf Granularität Person × Zuweisung.
  </Card>

  <Card title="E-Mail-Phishing-Protokoll" href="/de/developers/v1/reports/email-phishing-log">
    Phishing-Interaktionen auf Ereignisebene, letzte 12 Monate.
  </Card>

  <Card title="Anfälligste Benutzer" href="/de/developers/v1/reports/most-susceptible-users">
    Zusammenfassung von Klicks, Anmeldedaten und Meldungen pro Person über 12 Monate.
  </Card>
</CardGroup>

## Reporting-Preview-Datasets (Phishing pro Person)

Die Reporting-Preview-Familie stellt Datasets für Phishing pro Person bereit.
Sie erfordert `reporting:read` und Reporting-Zugriff für Ihre Organisation.
Verwenden Sie den folgenden Dataset-Katalog, um Schemas und Datenrichtlinien einzusehen.

Den Live-Katalog abrufen:

```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
```

Jedes Dataset deklariert Granularität, Primärschlüssel, Joins, Wasserzeichen sowie Korrektur- und
Löschrichtlinie.

### simulation\_results

* **Pfad:** `GET /v1/reporting/simulation_results`
* **Granularität:** Entität × Simulation
* **Filter:** `simulation`, `entity`, `updated_after` sowie `limit` /
  `page_token`
* **Wire-Objekt:** `reporting.simulation_result`
* **Join-Schlüssel:** `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
```

Verwenden Sie `updated_after` als inklusive untere Wasserzeichengrenze, wenn Sie aktuelle
Partitionen aktualisieren. Veränderliche Ergebnisdimensionen werden unter demselben `id` an Ort und Stelle neu dargestellt.

### simulation\_events

* **Pfad:** `GET /v1/reporting/simulation_events`
* **Granularität:** ein typisierter Fakt pro beobachteter Interaktion
* **Filter:** `simulation`, `entity`, `event_type`, `occurred_after` sowie
  `limit` / `page_token`
* **Wire-Objekt:** `reporting.simulation_event`
* **Ereignistypen:** geschlossene öffentliche Token wie `simulation.delivered`,
  `simulation.opened`, `simulation.link_clicked`,
  `simulation.credentials_submitted`, `simulation.reported` – keine rohen
  ClickHouse-`phishing.simulation.*`-Aktivitätstypen
* **Gerät:** `device_type` ist die abgeleitete Klasse (beobachtete Token sind unter anderem
  `desktop`, `mobile`, `tablet`, `unknown`). Roher `user_agent` ist nicht in
  `/v1` enthalten. Die rohe IP ist nie in `/v1` enthalten (wird nie persistiert; nur gesalzener `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
```

Verwenden Sie `occurred_after` für inkrementelle Ereignisladevorgänge. Fakten sind nur anhängend;
verspätet eintreffende Daten sind neue Zeilen.

### entities

* **Pfad:** `GET /v1/reporting/entities`
* **Granularität:** eine aktuelle Zeile pro Entität
* **Filter:** `type`, `updated_after` sowie `limit` / `page_token`
* **Wire-Objekt:** `reporting.entity`
* **Join-Schlüssel:** `id` / `entity_id` (gleicher Wert)

Verknüpfen Sie Phishing-Fakten mit Personen über
`reporting.simulation_results.entity_id = reporting.entities.id`.
Der operative Endpunkt `GET /v1/entities` bleibt bei `entity:read` und erfordert eine andere
Berechtigung.

```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 und cohort\_memberships

Diese beiden Datasets haben eine benannte Ausnahme, die nur den aktuellen Stand abbildet:

> Es existiert keine maßgebliche Tabelle mit Versions- oder Mitgliedschaftshistorie. Dieses Dataset bildet die aktuelle Zeile als ein offenes Intervall ab (`valid_to` ist null, `is_current` ist true). `valid_from` ist bei Entitätsversionen `created_at` oder andernfalls `updated_at` und bei Kohortenmitgliedschaften der Beobachtungszeitpunkt der Anfrage. Geschlossene historische Intervalle werden nicht erfunden.

* **entity\_versions:** `GET /v1/reporting/entity_versions`. Dieselben Filter wie bei
  entities. `valid_from` ist `created_at` oder andernfalls `updated_at`. Eine Entität
  mit beiden Zeitstempeln null wird ausgelassen.
* **cohort\_memberships:** `GET /v1/reporting/cohort_memberships`. Filter:
  `cohort`, `entity`, `updated_after`. `updated_at` ist die letzte Aktualisierung der Kohorte,
  damit die inkrementelle Aktualisierung korrekt bleibt. `valid_from` ist der
  Beobachtungszeitpunkt der Anfrage. Verknüpfen Sie `entity_id` mit `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
```

## Sternschema-Semantikmodell auf /v1/activities

`/v1/activities` ist der mit Zeitstempeln versehene Ereignisstrom der Organisation. Er hat keine
numerischen Kennzahlen – jede Größe in einem Bericht ist eine Zeilenanzahl –, weshalb
ein Sternschema naheliegt.

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

* **`Activities`** ist der Ereignisfakt in echter Granularität. Eine Zeile pro Aktivität.
  Teilnehmer werden hier entfernt und als eigene Tabelle neu aufgebaut.
* **`ActivityParticipants`** existiert, weil Teilnehmer tatsächlich
  eine Eins-zu-viele-Beziehung bilden, mit Rollen wie `actor` und `target`. Ein Abflachen auf
  `Activities` würde entweder Aktivitätszeilen duplizieren (und damit jede Zählung aufblähen)
  oder zusätzliche Teilnehmer verwerfen.
  Die Beziehung zu `Activities` ist die einzige bidirektionale
  Beziehung des Modells, sodass ein Entitäts-Slicer die Faktentabelle erreicht.
* **`Entities`** ist die Dimension, die Teilnehmer benennt. Teilnehmerzeilen
  einer Aktivität enthalten `entity_id` und eine Rolle, aber keinen Anzeigenamen.
* **`Dates`** wird in M erzeugt (null API-Aufrufe) und als Datumstabelle des Modells
  markiert. Dadurch sind DAX-Zeitintelligenz-Funktionen
  (`DATESINPERIOD`, `DATEADD`) zulässig. Spalten für Monats- und Wochentagsnamen deklarieren
  `sortByColumn` auf ihren Zahlen-Pendants, damit Visuals Monate nicht
  alphabetisch sortieren.

Alle Kennzahlen sind explizit (das Modell setzt `discourageImplicitMeasures`): Gesamtzahl der
Aktivitäten, aktive Entitäten, Aktivitäten pro aktiver Entität, Tage mit Aktivität
und ein nachlaufender 7-Tage-Tagesdurchschnitt.

### Form der Bridge-Tabelle

`ActivityParticipants` durchläuft `/v1/activities` und löst das `participants`-Array
in eine Zeile pro Teilnehmer auf:

```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` durchläuft `/v1/activities` unabhängig, statt
die Abfrage `Activities` zu referenzieren. In Power BI rufen zwei Abfragen, die als zwei
Tabellen geladen werden, bei der Aktualisierung jeweils unabhängig Daten ab. Werden sie unabhängig gehalten,
kann der Dienst die Partitionen beider Tabellen parallel aktualisieren.

## Inkrementelle Aktualisierung

Beide Tabellen auf Faktenebene (`Activities` und `ActivityParticipants`) deklarieren eine
Richtlinie für die inkrementelle Aktualisierung in Power BI: 5 Jahre an jährlichen Partitionen vorhalten,
die letzten 14 Tage bei jeder Aktualisierung erneut importieren.

Der Mechanismus besteht aus einem Paar von DateTime-Parametern, `RangeStart` und `RangeEnd`,
die der Power BI Service bei der Aktualisierung pro Partition neu bindet. Jede Faktenabfrage
übernimmt diese Parameter in die API-Filter `start_date` und `end_date`,
sodass das Anfragevolumen pro Partition proportional zur Partition ist und
nicht zur gesamten Historie. Die Abfrage weitet das API-Fenster auf jeder Seite um eine Minute aus und wendet die exakte halboffene Grenze `[RangeStart, RangeEnd)`
lokal erneut an, sodass die Korrektheit der Partition nicht davon abhängt, ob der Server
seine Grenzen inklusiv oder exklusiv behandelt.

```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
```

Die erste Aktualisierung nach einer Bereitstellung wendet die Richtlinie an: ein vollständiger Ladevorgang der Historie,
langsam. Jede weitere Aktualisierung berührt nur noch das inkrementelle Fenster.

Gemessen an einem Dataset mit ca. 158.000 Aktivitäten und ca. 2.300 Entitäten:

| Aktualisierung | Laufzeit |
| - | - |
| Erste (Anwendung der Richtlinie, vollständiger historischer Ladevorgang) | \~31 min |
| Jede weitere Aktualisierung | **\~15 s** |

<Warning>
  Der klassische Power-BI-REST-Endpunkt für die Aktualisierung (`POST .../refreshes` mit nur
  `notifyOption`) ignoriert Aktualisierungsrichtlinien stillschweigend und führt jedes Mal einen vollständigen Durchlauf aus.
  Um die Richtlinie über die REST-API anzuwenden, verwenden Sie die erweiterte Aktualisierung: Senden Sie
  `type`, `commitMode` und `applyRefreshPolicy` im Body. Vom Scheduler des
  Power BI Service ausgelöste Aktualisierungen wenden Richtlinien immer an – diese Eigenheit betrifft nur den REST-Aufruf.
</Warning>

<Note>
  Das 14-Tage-Fenster ist bewusst großzügig gewählt. Die Aktivitätsaufnahme erfolgt nicht
  ausschließlich anhängend zum aktuellen Zeitpunkt: Backfills von Konnektoren liefern Ereignisse mit zurückliegenden Zeitstempeln.
  Ein Fenster von 1–2 Tagen würde diese dauerhaft verpassen.
</Note>

## Das Aktualisierungsfenster in die API hinein ausweiten

`GET /v1/activities` akzeptiert die Filter `start_date` und `end_date`, in die die
Power-BI-Parameter einfließen, und filtert `event_ts` zwischen den Grenzen.
`Entities` ist eine Dimension, daher aktualisiert dieses Beispiel sie jedes Mal vollständig
statt inkrementell.

Reporting-Datasets verwenden ihre eigenen Wasserzeichen (`updated_after`,
`occurred_after`) anstelle des Datumsfensters der Aktivitäten.

## Rate Limits während der Aktualisierung

`/v1` erzwingt getrennte Budgets pro Schlüssel und pro IP (siehe
[Rate Limits](/de/developers/v1/rate-limits)). Beachten Sie `429` + `Retry-After`;
codieren Sie kein Anfragebudget fest in Ihr Modell. Zwei Eigenschaften von Power-BI-Aktualisierungen
sind relevant:

* **Der Durchlauf einer einzelnen Tabelle erfolgt sequenziell.** Jede Seitenanfrage benötigt das
  Token der vorherigen Seite, sodass eine Tabelle unabhängig von der Seitengröße latenzgebunden ist.
* **Gleichzeitige Tabellenaktualisierungen vervielfachen sich.** Wenn mehrere große Tabellen
  parallel aktualisiert werden, wird die Obergrenze erreicht, weshalb der 429-
  Retry-Pfad in `Fn_FetchAll` wichtig ist. Bevorzugen Sie weniger parallele Tabellen, statt
  von unbegrenzter Parallelität auszugehen.

Der Retry wurde gegen einen real ausgelasteten Limiter getestet. Eine Aktualisierung,
die lief, während eine parallele Last den Schlüssel bewusst an seiner Obergrenze hielt,
wurde dennoch abgeschlossen und lieferte dieselben Zeilenanzahlen wie eine ungestörte Baseline –
Retries lieferten vollständige Seiten, keine Lücken.

## Vertragsversionen und gespeicherte Anmeldedaten

`/v1` akzeptiert einen Anfrage-Header `Living-Security-Version`, um eine veröffentlichte
Vertragsversion zu fixieren (siehe [API-Schnellstart](/de/developers/v1/quickstart)). Power
Query kann diesen Header nicht senden, wenn die Datenquelle gespeicherte Anmeldedaten verwendet –
nur anonyme Datenquellen erlauben benutzerdefinierte Header. Wird er hinzugefügt, schlägt eine Aktualisierung
fehl mit:

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

Belassen Sie den Schlüssel in den gespeicherten Anmeldedaten von Power BI und lassen Sie den benutzerdefinierten Header weg.
Ohne explizite Version in der Anfrage verwendet die API das fixierte `contract_version` des
Dienstkontos oder die neueste veröffentlichte Version, wenn keine Fixierung gesetzt ist.
Klären Sie die Vertragsversion des Kontos mit Ihrem API-Administrator, bevor Sie
eine Aktualisierung veröffentlichen. Unterstützte Versionen finden Sie im [API-Changelog](/de/developers/v1/changelog).

Wenn sich eine Aktualisierung unerwartet verhält, prüfen Sie den
API-Antwort-Header `Living-Security-Version`, um festzustellen, welcher Vertrag die Anfrage
tatsächlich bedient hat.

## Ihr Modell aktualisieren und prüfen

Veröffentlichen Sie das Semantikmodell in Ihrem Power-BI-Arbeitsbereich, binden Sie die gespeicherten
Anmeldedaten an die regionale `/v1`-Quelle und führen Sie eine Aktualisierung aus, bevor Sie einen
Zeitplan aktivieren. Prüfen Sie den Aktualisierungsverlauf auf Fehler oder unvollständige Lesevorgänge.

Vergleichen Sie Zeilenanzahlen mit einem unabhängigen API-Lesevorgang mit denselben Filtern.
Der [Paginierungsleitfaden](/de/developers/v1/pagination) enthält Python- und cURL-Beispiele
zum Lesen eines vollständigen Ergebnissatzes. Eine Aktualisierung, die nur die
erste Seite liest, kann bei einem kleinen Dataset erfolgreich aussehen; vergleichen Sie die Anzahlen, bevor
Sie sich auf den Bericht verlassen.

## Modellentscheidungen

* **Kampagnensummen und Ergebnisse pro Person haben unterschiedliche Granularität.**
  `SimulationResults` ruft `GET /v1/simulations/{id}/results` auf
  (ein GET pro Durchlauf). Für Phishing pro Person fügen Sie Abfragen für
  `GET /v1/reporting/simulation_results` und
  `GET /v1/reporting/simulation_events` hinzu (oder den operativen
  `GET /v1/simulation_results`, wenn Sie bereits `simulation:read` besitzen).
* **`vectors` bei `/v1/simulations` wird zu einem kommagetrennten String abgeflacht.**
  Eine Spalte vom Listentyp kann nicht als echte Dimension gefiltert werden. Wenn eine Berichtsseite
  nach Vektor filtern muss, fügen Sie eine Bridge-Tabelle zwischen Simulation und Vektor hinzu.

## Nächste Schritte

<CardGroup cols={2}>
  <Card title="Developer Hub" href="/de/developers/developer-hub/overview">
    Erstellen Sie ein Dienstkonto und einen API-Schlüssel für Power BI.
  </Card>

  <Card title="API-Schnellstart" href="/de/developers/v1/quickstart">
    Authentifizieren Sie `/v1`-Anfragen mit Basic-Authentifizierung.
  </Card>

  <Card title="Paginierung" href="/de/developers/v1/pagination">
    Die Listen-Hülle, Cursor-Token und Filterstabilität über einen Durchlauf hinweg.
  </Card>

  <Card title="Rate Limits" href="/de/developers/v1/rate-limits">
    Budgets pro Schlüssel und pro IP, der 429-Vertrag und Hinweise zu Retries.
  </Card>
</CardGroup>


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