{
  "name": "Padlet organization analytics",
  "description": "Reads Padlet organization analytics through the public API: a Metrics table and a Users roster. Set the ApiKey and OrgId parameters after importing.",
  "version": "1.0",
  "culture": "en-US",
  "pbi:mashup": {
    "fastCombine": false,
    "allowNativeQueries": false,
    "queriesMetadata": {
      "ApiKey": {
        "queryId": "7f1c6a2e-4b5d-4f8a-9c31-0d6ea2b7c003",
        "queryName": "ApiKey",
        "loadEnabled": false
      },
      "OrgId": {
        "queryId": "7f1c6a2e-4b5d-4f8a-9c31-0d6ea2b7c004",
        "queryName": "OrgId",
        "loadEnabled": false
      },
      "PadletAnalytics": {
        "queryId": "7f1c6a2e-4b5d-4f8a-9c31-0d6ea2b7c001",
        "queryName": "PadletAnalytics",
        "loadEnabled": false
      },
      "Metrics": {
        "queryId": "7f1c6a2e-4b5d-4f8a-9c31-0d6ea2b7c002",
        "queryName": "Metrics",
        "loadEnabled": true
      },
      "PadletWaitFor": {
        "queryId": "7f1c6a2e-4b5d-4f8a-9c31-0d6ea2b7c005",
        "queryName": "PadletWaitFor",
        "loadEnabled": false
      },
      "PadletAnalyticsUsers": {
        "queryId": "7f1c6a2e-4b5d-4f8a-9c31-0d6ea2b7c006",
        "queryName": "PadletAnalyticsUsers",
        "loadEnabled": false
      },
      "Users": {
        "queryId": "7f1c6a2e-4b5d-4f8a-9c31-0d6ea2b7c007",
        "queryName": "Users",
        "loadEnabled": true
      }
    },
    "document": "section Section1;\r\n\r\nshared ApiKey = \"pdltp_YOUR_API_KEY\" meta [IsParameterQuery = true, Type = \"Text\", IsParameterQueryRequired = true];\r\n\r\nshared OrgId = \"wksp_YOUR_ORG_ID\" meta [IsParameterQuery = true, Type = \"Text\", IsParameterQueryRequired = true];\r\n\r\n// Microsoft's wait-retry pattern, shared by both endpoint functions. Named\r\n// PadletWaitFor because Power Query already exports an undocumented WaitFor, and\r\n// a section member may not redefine it.\r\nshared PadletWaitFor = (producer as function, interval as function, count as number) as any =>\r\n    let\r\n        attempts = List.Generate(\r\n            () => {0, null},\r\n            (state) => state{0} <= count,\r\n            (state) =>\r\n                if state{1} <> null then\r\n                    // Carry the result forward. List.Generate never yields a\r\n                    // state its condition rejects, so a success on the last\r\n                    // allowed attempt would otherwise be thrown away.\r\n                    {state{0} + 1, state{1}}\r\n                else if state{0} >= count then\r\n                    // Out of attempts: stop asking, and let the caller raise.\r\n                    {state{0} + 1, null}\r\n                else\r\n                    {state{0} + 1, Function.InvokeAfter(() => producer(state{0}), interval(state{0}))},\r\n            (state) => state{1}\r\n        )\r\n    in\r\n        List.Last(attempts, null);\r\n\r\nshared PadletAnalytics = let\r\n    // Static base URL, with the organization hashid in RelativePath. Concatenating the\r\n    // hashid into the URL instead makes this a dynamic data source, which the Power\r\n    // BI Service refuses to refresh on a schedule.\r\n    BaseUrl = \"https://api.padlet.dev\",\r\n\r\n    PadletAnalytics = (OrgId as text, ApiKey as text, Metrics as list, optional Options as record) as record =>\r\n        let\r\n            Given = Options ?? [],\r\n            RetrySeconds = Record.FieldOrDefault(Given, \"retrySeconds\", 60),\r\n            MaxAttempts = Record.FieldOrDefault(Given, \"maxAttempts\", 10),\r\n\r\n            // The endpoint wants from and to together or not at all, so refuse half a\r\n            // range here rather than sending a request that cannot succeed.\r\n            HasRange = Record.HasFields(Given, \"from\") and Record.HasFields(Given, \"to\"),\r\n            HalfRange = (Record.HasFields(Given, \"from\") or Record.HasFields(Given, \"to\")) and not HasRange,\r\n            Body = if HalfRange then\r\n                    error \"Padlet analytics needs both from and to, or neither\"\r\n                else\r\n                    [metrics = Metrics]\r\n                        & (if Record.HasFields(Given, \"dimensions\") then [dimensions = Given[dimensions]] else [])\r\n                        & (if HasRange then [from = Given[from], to = Given[to]] else []),\r\n\r\n            Attempt = (iteration) =>\r\n                let\r\n                    Response = Web.Contents(\r\n                        BaseUrl,\r\n                        [\r\n                            RelativePath = \"v1/organizations/\" & OrgId & \"/analytics\",\r\n                            Headers = [\r\n                                #\"X-API-KEY\" = ApiKey,\r\n                                // Web.Contents posts form-encoded unless told otherwise, and the\r\n                                // endpoint answers 415 to that.\r\n                                #\"Content-Type\" = \"application/json\",\r\n                                #\"Accept\" = \"application/vnd.api+json\"\r\n                            ],\r\n                            // Supplying Content is what makes this a POST.\r\n                            Content = Json.FromValue(Body),\r\n                            ManualStatusHandling = {202},\r\n                            // Every poll repeats the same URL, headers and body, so without\r\n                            // this Power Query can serve its cached 202 forever.\r\n                            IsRetry = iteration > 0\r\n                        ]\r\n                    ),\r\n                    Status = Value.Metadata(Response)[Response.Status],\r\n                    Parsed = if Status = 202 then null else Json.Document(Response),\r\n                    Failed = if Parsed = null then {} else Record.FieldOrDefault(Parsed[meta], \"errors\", {})\r\n                in\r\n                    // null asks WaitFor to try again; anything else ends the loop. A\r\n                    // partial answer is a 200 with metrics missing from data, so refuse\r\n                    // it rather than load an incomplete table.\r\n                    if Status = 202 then\r\n                        null\r\n                    else if List.Count(Failed) > 0 then\r\n                        error \"Padlet analytics could not compute \"\r\n                            & Text.Combine(List.Transform(Failed, (m) => m[name]), \", \")\r\n                    else\r\n                        Parsed,\r\n\r\n            // WaitFor delays every producer call, including the first, so iteration 0\r\n            // waits nothing: a warm request answers immediately.\r\n            Result = #\"PadletWaitFor\"(\r\n                Attempt,\r\n                (iteration) => if iteration = 0 then #duration(0, 0, 0, 0) else #duration(0, 0, 0, RetrySeconds),\r\n                MaxAttempts\r\n            )\r\n        in\r\n            if Result = null then\r\n                error \"Padlet analytics were still pending after \" & Text.From(MaxAttempts) & \" attempts\"\r\n            else\r\n                Result\r\nin\r\n    PadletAnalytics;\r\n\r\nshared Metrics = let\r\n    Response = #\"PadletAnalytics\"(\r\n        OrgId,\r\n        ApiKey,\r\n        {\r\n            [name = \"padletCount\"],\r\n            [name = \"postCount\"],\r\n            [name = \"memberCount\", status = \"active\"]\r\n        }\r\n    ),\r\n    Metrics = Response[data][attributes][metrics],\r\n    // Pick out scalars and declare the column types. Handing the metric records\r\n    // to Table.FromRecords as they are leaves records and lists in the cells,\r\n    // which the report can display but not load.\r\n    Rows = List.Transform(Metrics, (m) => [metric = m[name], value = m[data]]),\r\n    Result = Table.FromRecords(Rows, type table [metric = text, value = number])\r\nin\r\n    Result;\r\n\r\nshared PadletAnalyticsUsers = let\r\n    BaseUrl = \"https://api.padlet.dev\",\r\n\r\n    PadletAnalyticsUsers = (OrgId as text, ApiKey as text, optional Options as record) as table =>\r\n        let\r\n            Given = Options ?? [],\r\n            RetrySeconds = Record.FieldOrDefault(Given, \"retrySeconds\", 60),\r\n            MaxAttempts = Record.FieldOrDefault(Given, \"maxAttempts\", 10),\r\n            PerPage = Record.FieldOrDefault(Given, \"perPage\", 100),\r\n\r\n            // 100 is the endpoint's own cap; asking for more is a 400. Fail here\r\n            // rather than spend a request finding out.\r\n            CheckedPerPage = if PerPage > 100 or PerPage < 1 then\r\n                    error \"perPage must be between 1 and 100\"\r\n                else\r\n                    PerPage,\r\n\r\n            HasRange = Record.HasFields(Given, \"from\") and Record.HasFields(Given, \"to\"),\r\n            HalfRange = (Record.HasFields(Given, \"from\") or Record.HasFields(Given, \"to\")) and not HasRange,\r\n\r\n            // status and userType each take one value or several, so both forms are\r\n            // widened to a list before the query string is built.\r\n            AsList = (value) => if value is list then value else {value},\r\n            Filter = (name as text) =>\r\n                if Record.HasFields(Given, name) then\r\n                    List.Transform(AsList(Record.Field(Given, name)), (v) => {name & \"[]\", Text.From(v)})\r\n                else\r\n                    {},\r\n            Single = (name as text) =>\r\n                if Record.HasFields(Given, name) then {{name, Text.From(Record.Field(Given, name))}} else {},\r\n\r\n            Pairs = if HalfRange then\r\n                    error \"Padlet analytics needs both from and to, or neither\"\r\n                else\r\n                    List.Combine({\r\n                        if HasRange then {{\"from\", Text.From(Given[from])}, {\"to\", Text.From(Given[to])}} else {},\r\n                        Filter(\"status\"),\r\n                        Filter(\"userType\"),\r\n                        Single(\"q\"),\r\n                        Single(\"sort\"),\r\n                        Single(\"order\")\r\n                    }),\r\n\r\n            // The whole query string goes into RelativePath: a Query record cannot\r\n            // express status[] twice, and only the base URL has to stay static for\r\n            // the Power BI Service to accept a scheduled refresh.\r\n            Encoded = Text.Combine(\r\n                List.Transform(Pairs, (p) => Uri.EscapeDataString(p{0}) & \"=\" & Uri.EscapeDataString(p{1})),\r\n                \"&\"\r\n            ),\r\n\r\n            FetchPage = (page as number) as record =>\r\n                let\r\n                    Path = \"v1/organizations/\" & OrgId & \"/analytics/users?\"\r\n                        & (if Encoded = \"\" then \"\" else Encoded & \"&\")\r\n                        & \"page=\" & Text.From(page)\r\n                        & \"&perPage=\" & Text.From(CheckedPerPage),\r\n                    Attempt = (iteration) =>\r\n                        let\r\n                            Response = Web.Contents(\r\n                                BaseUrl,\r\n                                [\r\n                                    RelativePath = Path,\r\n                                    Headers = [\r\n                                        #\"X-API-KEY\" = ApiKey,\r\n                                        #\"Accept\" = \"application/vnd.api+json\"\r\n                                    ],\r\n                                    // 429 is handled here so a walk that runs out of\r\n                                    // requests says so, instead of failing as a bare\r\n                                    // HTTP error.\r\n                                    ManualStatusHandling = {202, 429},\r\n                                    // Every poll repeats the same URL, so without this\r\n                                    // Power Query can serve its cached 202 forever.\r\n                                    IsRetry = iteration > 0\r\n                                ]\r\n                            ),\r\n                            Status = Value.Metadata(Response)[Response.Status],\r\n                            // A 202 carries no body and a 429 need not carry JSON, so\r\n                            // neither is parsed. M binds lazily, so this only documents\r\n                            // what the branches below already avoid.\r\n                            Parsed = if Status = 202 or Status = 429 then null else Json.Document(Response),\r\n                            Failed = if Parsed = null then {} else Record.FieldOrDefault(Parsed[meta], \"errors\", {})\r\n                        in\r\n                            if Status = 429 then\r\n                                error \"Padlet allows 60 analytics requests an hour for this key and organization, \"\r\n                                    & \"and this refresh has spent them. Raise perPage, narrow the filters, or wait.\"\r\n                            else if Status = 202 then\r\n                                null\r\n                            else if List.Count(Failed) > 0 then\r\n                                error \"Padlet could not compute the analytics roster\"\r\n                            else\r\n                                Parsed,\r\n                    Result = #\"PadletWaitFor\"(\r\n                        Attempt,\r\n                        (iteration) => if iteration = 0 then #duration(0, 0, 0, 0) else #duration(0, 0, 0, RetrySeconds),\r\n                        MaxAttempts\r\n                    )\r\n                in\r\n                    if Result = null then\r\n                        error \"The Padlet analytics roster was still pending after \" & Text.From(MaxAttempts) & \" attempts\"\r\n                    else\r\n                        Result,\r\n\r\n            // Page 1 reports how many pages there are, so the rest are fetched\r\n            // without guessing. One request per page counts against the hourly limit.\r\n            First = FetchPage(1),\r\n            TotalPages = Record.FieldOrDefault(First[meta], \"totalPages\", 0),\r\n            Rest = if TotalPages <= 1 then {} else List.Transform({2 .. TotalPages}, FetchPage),\r\n            Rows = List.Combine(List.Transform(List.Combine({{First}, Rest}), (d) => d[data])),\r\n\r\n            // Built field by field: Table.FromRecords matches the declared type by\r\n            // position, and the dates arrive as ISO strings that Power BI will not\r\n            // treat as dates until they are converted. datetime, not date, so the\r\n            // column type matches the dateTime the model's entity declares -- date\r\n            // is not one of the nine dataTypes model.json accepts.\r\n            AsDate = (value) => if value = null then null else DateTime.From(Date.FromText(value)),\r\n            Flat = List.Transform(Rows, (r) => [\r\n                id = r[id],\r\n                name = r[attributes][name],\r\n                username = r[attributes][username],\r\n                email = r[attributes][email],\r\n                userType = r[attributes][userType],\r\n                active = r[attributes][active],\r\n                signupDate = AsDate(r[attributes][signupDate]),\r\n                lastActivityDate = AsDate(r[attributes][lastActivityDate]),\r\n                padlets = r[attributes][padlets],\r\n                posts = r[attributes][posts],\r\n                comments = r[attributes][comments],\r\n                reactions = r[attributes][reactions]\r\n            ])\r\n        in\r\n            Table.FromRecords(\r\n                Flat,\r\n                type table [\r\n                    id = text,\r\n                    name = nullable text,\r\n                    username = nullable text,\r\n                    email = nullable text,\r\n                    userType = text,\r\n                    active = logical,\r\n                    signupDate = nullable datetime,\r\n                    lastActivityDate = nullable datetime,\r\n                    padlets = Int64.Type,\r\n                    posts = Int64.Type,\r\n                    comments = Int64.Type,\r\n                    reactions = Int64.Type\r\n                ]\r\n            )\r\nin\r\n    PadletAnalyticsUsers;\r\n\r\nshared Users = let\r\n    // A let expression, not a bare call: the dataflow importer rejects the whole\r\n    // model if a load-enabled query is a plain function call.\r\n    Source = #\"PadletAnalyticsUsers\"(\r\n        OrgId,\r\n        ApiKey,\r\n        [\r\n            // Filters go here: status = \"active\", userType = {\"teacher\", \"admin\"},\r\n            // q = \"smith\", sort = \"posts\", order = \"desc\".\r\n            perPage = 100\r\n        ]\r\n    )\r\nin\r\n    Source;\r\n"
  },
  "entities": [
    {
      "$type": "LocalEntity",
      "name": "Metrics",
      "description": "One row per metric requested.",
      "attributes": [
        {
          "name": "metric",
          "dataType": "string"
        },
        {
          "name": "value",
          "dataType": "double"
        }
      ],
      "pbi:refreshPolicy": {
        "$type": "FullRefreshPolicy",
        "location": "Metrics.csv"
      }
    },
    {
      "$type": "LocalEntity",
      "name": "Users",
      "description": "One row per current member of the organization.",
      "attributes": [
        {
          "name": "id",
          "dataType": "string"
        },
        {
          "name": "name",
          "dataType": "string"
        },
        {
          "name": "username",
          "dataType": "string"
        },
        {
          "name": "email",
          "dataType": "string"
        },
        {
          "name": "userType",
          "dataType": "string"
        },
        {
          "name": "active",
          "dataType": "boolean"
        },
        {
          "name": "signupDate",
          "dataType": "dateTime"
        },
        {
          "name": "lastActivityDate",
          "dataType": "dateTime"
        },
        {
          "name": "padlets",
          "dataType": "int64"
        },
        {
          "name": "posts",
          "dataType": "int64"
        },
        {
          "name": "comments",
          "dataType": "int64"
        },
        {
          "name": "reactions",
          "dataType": "int64"
        }
      ],
      "pbi:refreshPolicy": {
        "$type": "FullRefreshPolicy",
        "location": "Users.csv"
      }
    }
  ],
  "annotations": [
    {
      "name": "pbi:QueryGroups",
      "value": "[]"
    }
  ]
}
