From 40fc911af7cbe68fc5f994cc6c2d15b10b6e673f Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Thu, 18 Dec 2025 14:37:40 +0800 Subject: [PATCH 1/2] feat: [PPT-2327] Add MS Planner API --- drivers/microsoft/graph_api_advanced.cr | 118 ++++++++++++++++++++++-- 1 file changed, 112 insertions(+), 6 deletions(-) diff --git a/drivers/microsoft/graph_api_advanced.cr b/drivers/microsoft/graph_api_advanced.cr index 6d6ce04290e..fb4766eb10d 100644 --- a/drivers/microsoft/graph_api_advanced.cr +++ b/drivers/microsoft/graph_api_advanced.cr @@ -22,14 +22,16 @@ class Microsoft::GraphAPIAdvanced < PlaceOS::Driver client_secret: String, ) + getter! client : Office365::Client + def on_update credentials = setting(GraphParams, :credentials) @client = Office365::Client.new(**credentials) end private def get(path : String, query_params : URI::Params? = nil) - @client.not_nil!.graph_request( - @client.not_nil!.graph_http_request( + client.graph_request( + client.graph_http_request( request_method: "GET", path: path, query: query_params @@ -43,8 +45,8 @@ class Microsoft::GraphAPIAdvanced < PlaceOS::Driver end private def post(path : String, query_params : URI::Params? = nil, body : String? = nil) - @client.not_nil!.graph_request( - @client.not_nil!.graph_http_request( + client.graph_request( + client.graph_http_request( request_method: "POST", path: path, data: body, @@ -59,8 +61,8 @@ class Microsoft::GraphAPIAdvanced < PlaceOS::Driver end private def put(path : String, query_params : URI::Params? = nil, body : String? = nil) - @client.not_nil!.graph_request( - @client.not_nil!.graph_http_request( + client.graph_request( + client.graph_http_request( request_method: "PUT", path: path, data: body, @@ -89,4 +91,108 @@ class Microsoft::GraphAPIAdvanced < PlaceOS::Driver ) response.body["value"] end + + # ===================== + # Planner API + # ===================== + + # List plans for a group + # https://learn.microsoft.com/en-us/graph/api/plannergroup-list-plans + def list_plans(group_id : String) + response = get("/v1.0/groups/#{group_id}/planner/plans") + JSON.parse(response.body).as_h["value"] + end + + # Get a plan by ID + # https://learn.microsoft.com/en-us/graph/api/plannerplan-get + def get_plan(plan_id : String) + response = get("/v1.0/planner/plans/#{plan_id}") + JSON.parse(response.body) + end + + # Create a new plan + # https://learn.microsoft.com/en-us/graph/api/planner-post-plans + def create_plan(group_id : String, title : String) + body = { + container: { + url: "https://graph.microsoft.com/v1.0/groups/#{group_id}", + }, + title: title, + }.to_json + response = post("/v1.0/planner/plans", body: body) + JSON.parse(response.body) + end + + # List buckets for a plan + # https://learn.microsoft.com/en-us/graph/api/plannerplan-list-buckets + def list_buckets(plan_id : String) + response = get("/v1.0/planner/plans/#{plan_id}/buckets") + JSON.parse(response.body).as_h["value"] + end + + # Create a bucket in a plan + # https://learn.microsoft.com/en-us/graph/api/planner-post-buckets + def create_bucket(plan_id : String, name : String, order_hint : String? = nil) + body = { + name: name, + planId: plan_id, + orderHint: order_hint || " !", + }.to_json + response = post("/v1.0/planner/buckets", body: body) + JSON.parse(response.body) + end + + # List tasks for a plan + # https://learn.microsoft.com/en-us/graph/api/plannerplan-list-tasks + def list_tasks(plan_id : String) + response = get("/v1.0/planner/plans/#{plan_id}/tasks") + JSON.parse(response.body).as_h["value"] + end + + # Create a task in a plan + # https://learn.microsoft.com/en-us/graph/api/planner-post-tasks + # assigned_to_user_ids: array of user IDs to assign the task to + # priority: 0-10 (0=highest, 10=lowest). 1=urgent, 3=important, 5=medium, 9=low + def create_task( + plan_id : String, + title : String, + bucket_id : String? = nil, + assigned_to_user_ids : Array(String)? = nil, + due_date_time : String? = nil, + start_date_time : String? = nil, + percent_complete : Int32? = nil, + priority : Int32? = nil, + order_hint : String? = nil, + ) + body = JSON.build do |json| + json.object do + json.field "planId", plan_id + json.field "title", title + json.field "bucketId", bucket_id if bucket_id + json.field "dueDateTime", due_date_time if due_date_time + json.field "startDateTime", start_date_time if start_date_time + json.field "percentComplete", percent_complete if percent_complete + json.field "priority", priority if priority + json.field "orderHint", order_hint if order_hint + + if assigned_to_user_ids && !assigned_to_user_ids.empty? + json.field "assignments" do + json.object do + assigned_to_user_ids.each do |user_id| + json.field user_id do + json.object do + json.field "@odata.type", "#microsoft.graph.plannerAssignment" + json.field "orderHint", " !" + end + end + end + end + end + end + end + end + + response = post("/v1.0/planner/tasks", body: body) + JSON.parse(response.body) + end end From 94bbc9f68e208526aa5a2328bfb02ad082d55850 Mon Sep 17 00:00:00 2001 From: Ali Naqvi Date: Fri, 16 Jan 2026 11:45:27 +0800 Subject: [PATCH 2/2] refactor(microsoft): move Intune and Planner APIs to calendar_common, simplify graph_api_advanced --- drivers/microsoft/graph_api_advanced.cr | 190 +------------------ drivers/place/calendar_common.cr | 240 ++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 185 deletions(-) diff --git a/drivers/microsoft/graph_api_advanced.cr b/drivers/microsoft/graph_api_advanced.cr index fb4766eb10d..3f1bd1d514f 100644 --- a/drivers/microsoft/graph_api_advanced.cr +++ b/drivers/microsoft/graph_api_advanced.cr @@ -1,198 +1,18 @@ -require "placeos-driver" -require "office365" +require "../place/calendar_common" class Microsoft::GraphAPIAdvanced < PlaceOS::Driver + include Place::CalendarCommon + descriptive_name "Direct Access to Microsoft Graph API" generic_name :MSGraphAPI - uri_base "https://graph.microsoft.com/" + uri_base "https://graph.microsoft.com" default_settings({ - credentials: { + calendar_config: { tenant: "", client_id: "", client_secret: "", }, - }) - - alias GraphParams = NamedTuple( - tenant: String, - client_id: String, - client_secret: String, - ) - - getter! client : Office365::Client - - def on_update - credentials = setting(GraphParams, :credentials) - @client = Office365::Client.new(**credentials) - end - - private def get(path : String, query_params : URI::Params? = nil) - client.graph_request( - client.graph_http_request( - request_method: "GET", - path: path, - query: query_params - ) - ) - end - - @[Security(Level::Support)] - def get_request(path : String) - get(path) - end - - private def post(path : String, query_params : URI::Params? = nil, body : String? = nil) - client.graph_request( - client.graph_http_request( - request_method: "POST", - path: path, - data: body, - query: query_params - ) - ) - end - - @[Security(Level::Support)] - def post_request(path : String) - post(path) - end - - private def put(path : String, query_params : URI::Params? = nil, body : String? = nil) - client.graph_request( - client.graph_http_request( - request_method: "PUT", - path: path, - data: body, - query: query_params - ) - ) - end - - @[Security(Level::Support)] - def put_request(path : String) - put(path) - end - - def list_managed_devices(filter_device_name : String? = nil) - query_params = filter_device_name ? URI::Params{"filter" => "deviceName eq #{filter_device_name}"} : nil - response = get( - "/v1.0/deviceManagement/managedDevices", - query_params - ) - response.body["value"] - end - - def list_users_managed_devices(user_id : String) - response = get( - "/v1.0/users/#{user_id}/managedDevices" - ) - response.body["value"] - end - - # ===================== - # Planner API - # ===================== - - # List plans for a group - # https://learn.microsoft.com/en-us/graph/api/plannergroup-list-plans - def list_plans(group_id : String) - response = get("/v1.0/groups/#{group_id}/planner/plans") - JSON.parse(response.body).as_h["value"] - end - - # Get a plan by ID - # https://learn.microsoft.com/en-us/graph/api/plannerplan-get - def get_plan(plan_id : String) - response = get("/v1.0/planner/plans/#{plan_id}") - JSON.parse(response.body) - end - - # Create a new plan - # https://learn.microsoft.com/en-us/graph/api/planner-post-plans - def create_plan(group_id : String, title : String) - body = { - container: { - url: "https://graph.microsoft.com/v1.0/groups/#{group_id}", - }, - title: title, - }.to_json - response = post("/v1.0/planner/plans", body: body) - JSON.parse(response.body) - end - - # List buckets for a plan - # https://learn.microsoft.com/en-us/graph/api/plannerplan-list-buckets - def list_buckets(plan_id : String) - response = get("/v1.0/planner/plans/#{plan_id}/buckets") - JSON.parse(response.body).as_h["value"] - end - - # Create a bucket in a plan - # https://learn.microsoft.com/en-us/graph/api/planner-post-buckets - def create_bucket(plan_id : String, name : String, order_hint : String? = nil) - body = { - name: name, - planId: plan_id, - orderHint: order_hint || " !", - }.to_json - response = post("/v1.0/planner/buckets", body: body) - JSON.parse(response.body) - end - - # List tasks for a plan - # https://learn.microsoft.com/en-us/graph/api/plannerplan-list-tasks - def list_tasks(plan_id : String) - response = get("/v1.0/planner/plans/#{plan_id}/tasks") - JSON.parse(response.body).as_h["value"] - end - - # Create a task in a plan - # https://learn.microsoft.com/en-us/graph/api/planner-post-tasks - # assigned_to_user_ids: array of user IDs to assign the task to - # priority: 0-10 (0=highest, 10=lowest). 1=urgent, 3=important, 5=medium, 9=low - def create_task( - plan_id : String, - title : String, - bucket_id : String? = nil, - assigned_to_user_ids : Array(String)? = nil, - due_date_time : String? = nil, - start_date_time : String? = nil, - percent_complete : Int32? = nil, - priority : Int32? = nil, - order_hint : String? = nil, - ) - body = JSON.build do |json| - json.object do - json.field "planId", plan_id - json.field "title", title - json.field "bucketId", bucket_id if bucket_id - json.field "dueDateTime", due_date_time if due_date_time - json.field "startDateTime", start_date_time if start_date_time - json.field "percentComplete", percent_complete if percent_complete - json.field "priority", priority if priority - json.field "orderHint", order_hint if order_hint - - if assigned_to_user_ids && !assigned_to_user_ids.empty? - json.field "assignments" do - json.object do - assigned_to_user_ids.each do |user_id| - json.field user_id do - json.object do - json.field "@odata.type", "#microsoft.graph.plannerAssignment" - json.field "orderHint", " !" - end - end - end - end - end - end - end - end - - response = post("/v1.0/planner/tasks", body: body) - JSON.parse(response.body) - end end diff --git a/drivers/place/calendar_common.cr b/drivers/place/calendar_common.cr index 504c80d2eab..3579b538882 100644 --- a/drivers/place/calendar_common.cr +++ b/drivers/place/calendar_common.cr @@ -421,6 +421,246 @@ module Place::CalendarCommon client &.delete_notifier(subscription) end + # ===================================================== + # Microsoft Graph API - Intune Device Management + # ===================================================== + + # NOTE:: GraphAPI Only! + @[PlaceOS::Driver::Security(Level::Support)] + def list_managed_devices(filter_device_name : String? = nil) + logger.debug { "listing managed devices, filter: #{filter_device_name}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + query_params = filter_device_name ? URI::Params{"filter" => "deviceName eq #{filter_device_name}"} : nil + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "GET", + path: "/v1.0/deviceManagement/managedDevices", + query: query_params + ) + ) + JSON.parse(response.body).as_h["value"] + end + end + end + + # NOTE:: GraphAPI Only! + @[PlaceOS::Driver::Security(Level::Support)] + def list_users_managed_devices(user_id : String) + logger.debug { "listing managed devices for user: #{user_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "GET", + path: "/v1.0/users/#{user_id}/managedDevices" + ) + ) + JSON.parse(response.body).as_h["value"] + end + end + end + + # ===================================================== + # Microsoft Graph API - Planner + # ===================================================== + + # NOTE:: GraphAPI Only! + # List plans for a group + # https://learn.microsoft.com/en-us/graph/api/plannergroup-list-plans + @[PlaceOS::Driver::Security(Level::Support)] + def list_plans(group_id : String) + logger.debug { "listing plans for group: #{group_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "GET", + path: "/v1.0/groups/#{group_id}/planner/plans" + ) + ) + JSON.parse(response.body).as_h["value"] + end + end + end + + # NOTE:: GraphAPI Only! + # Get a plan by ID + # https://learn.microsoft.com/en-us/graph/api/plannerplan-get + @[PlaceOS::Driver::Security(Level::Support)] + def get_plan(plan_id : String) + logger.debug { "getting plan: #{plan_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "GET", + path: "/v1.0/planner/plans/#{plan_id}" + ) + ) + JSON.parse(response.body) + end + end + end + + # NOTE:: GraphAPI Only! + # Create a new plan + # https://learn.microsoft.com/en-us/graph/api/planner-post-plans + @[PlaceOS::Driver::Security(Level::Support)] + def create_plan(group_id : String, title : String) + logger.debug { "creating plan '#{title}' for group: #{group_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + body = { + container: { + url: "https://graph.microsoft.com/v1.0/groups/#{group_id}", + }, + title: title, + }.to_json + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "POST", + path: "/v1.0/planner/plans", + data: body + ) + ) + JSON.parse(response.body) + end + end + end + + # NOTE:: GraphAPI Only! + # List buckets for a plan + # https://learn.microsoft.com/en-us/graph/api/plannerplan-list-buckets + @[PlaceOS::Driver::Security(Level::Support)] + def list_buckets(plan_id : String) + logger.debug { "listing buckets for plan: #{plan_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "GET", + path: "/v1.0/planner/plans/#{plan_id}/buckets" + ) + ) + JSON.parse(response.body).as_h["value"] + end + end + end + + # NOTE:: GraphAPI Only! + # Create a bucket in a plan + # https://learn.microsoft.com/en-us/graph/api/planner-post-buckets + @[PlaceOS::Driver::Security(Level::Support)] + def create_bucket(plan_id : String, name : String, order_hint : String? = nil) + logger.debug { "creating bucket '#{name}' in plan: #{plan_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + body = { + name: name, + planId: plan_id, + orderHint: order_hint || " !", + }.to_json + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "POST", + path: "/v1.0/planner/buckets", + data: body + ) + ) + JSON.parse(response.body) + end + end + end + + # NOTE:: GraphAPI Only! + # List tasks for a plan + # https://learn.microsoft.com/en-us/graph/api/plannerplan-list-tasks + @[PlaceOS::Driver::Security(Level::Support)] + def list_tasks(plan_id : String) + logger.debug { "listing tasks for plan: #{plan_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "GET", + path: "/v1.0/planner/plans/#{plan_id}/tasks" + ) + ) + JSON.parse(response.body).as_h["value"] + end + end + end + + # NOTE:: GraphAPI Only! + # Create a task in a plan + # https://learn.microsoft.com/en-us/graph/api/planner-post-tasks + # assigned_to_user_ids: array of user IDs to assign the task to + # priority: 0-10 (0=highest, 10=lowest). 1=urgent, 3=important, 5=medium, 9=low + @[PlaceOS::Driver::Security(Level::Support)] + def create_task( + plan_id : String, + title : String, + bucket_id : String? = nil, + assigned_to_user_ids : Array(String)? = nil, + due_date_time : String? = nil, + start_date_time : String? = nil, + percent_complete : Int32? = nil, + priority : Int32? = nil, + order_hint : String? = nil, + ) + logger.debug { "creating task '#{title}' in plan: #{plan_id}, note: graphAPI only" } + client do |_client| + if _client.client_id == :office365 + office_client = _client.calendar.as(PlaceCalendar::Office365).client + body = JSON.build do |json| + json.object do + json.field "planId", plan_id + json.field "title", title + json.field "bucketId", bucket_id if bucket_id + json.field "dueDateTime", due_date_time if due_date_time + json.field "startDateTime", start_date_time if start_date_time + json.field "percentComplete", percent_complete if percent_complete + json.field "priority", priority if priority + json.field "orderHint", order_hint if order_hint + + if assigned_to_user_ids && !assigned_to_user_ids.empty? + json.field "assignments" do + json.object do + assigned_to_user_ids.each do |user_id| + json.field user_id do + json.object do + json.field "@odata.type", "#microsoft.graph.plannerAssignment" + json.field "orderHint", " !" + end + end + end + end + end + end + end + end + + response = office_client.graph_request( + office_client.graph_http_request( + request_method: "POST", + path: "/v1.0/planner/tasks", + data: body + ) + ) + JSON.parse(response.body) + end + end + end + protected def rate_limiter in_flight = @in_flight channel = @channel