@@ -497,7 +497,7 @@ def get_file_content_status(self) -> dict:
497497 Returns:
498498 dict: The file content status dictionary.
499499 """
500- url = f"{ self .BASE_ENDPOINT } /{ self .request ['id' ]} /microsoft.graph.macOSLobApp /contentVersions/{ self .content_version_request ['id' ]} /files/{ self .content_file_request ['id' ]} "
500+ url = f"{ self .BASE_ENDPOINT } /{ self .request ['id' ]} /{ self . app_odata_type_str } /contentVersions/{ self .content_version_request ['id' ]} /files/{ self .content_file_request ['id' ]} "
501501 return self .makeapirequest (url , self .token )
502502
503503 def delete_app (self ) -> None :
@@ -529,6 +529,26 @@ def wait_for_file_upload(self) -> None:
529529 self .delete_app ()
530530 raise ProcessorError ("Timed out waiting for file upload to complete" )
531531
532+ def wait_for_publishing_state (self , timeout : int = 60 ) -> None :
533+ """Waits for the app's publishingState to become 'published' before assignment.
534+
535+ Intune rejects /assign with 400 if publishingState is not 'published' (e.g.
536+ still 'processing' after a recent upload). Polls every 10 s up to `timeout` s.
537+ """
538+ app_id = self .request ["id" ]
539+ for _ in range (max (1 , timeout // 10 )):
540+ app = self .makeapirequest (f"{ self .BASE_ENDPOINT } /{ app_id } " , self .token )
541+ state = app .get ("publishingState" , "" )
542+ if state == "published" :
543+ return
544+ self .output (f"App publishingState is '{ state } ', waiting 10s..." )
545+ time .sleep (10 )
546+ raise ProcessorError (
547+ f"App '{ app_id } ' is not in a published state. "
548+ "The app may be stuck — delete it from the Intune admin center and re-run this recipe: "
549+ f"https://intune.microsoft.com/#view/Microsoft_Intune_Apps/SettingsMenu/~/0/appId/{ app_id } "
550+ )
551+
532552 def wait_for_azure_storage_uri (self ) -> None :
533553 """Waits for an Azure Storage upload URL to be generated.
534554
@@ -627,8 +647,6 @@ def get_current_app(self, displayname: str, version: int, odata_type: str) -> tu
627647 app
628648 for app in matching_apps
629649 if app ["displayName" ] == displayname
630- and app .get ("primaryBundleVersion" ) == version
631- or app .get ("buildNumber" ) == version
632650 and app ["@odata.type" ] == odata_type
633651 ]
634652 result = None
@@ -719,76 +737,51 @@ def assign_app(self, app, assignment_info: dict) -> None:
719737 current_assignment = self .makeapirequest (
720738 f"{ self .BASE_ENDPOINT } /{ self .request ['id' ]} /assignments" , self .token
721739 )
722- # Get the current group ids
723740 current_group_ids = [
724741 c ["target" ].get ("groupId" )
725742 for c in current_assignment ["value" ]
726743 if c ["target" ].get ("groupId" )
727744 ]
728- # Get the current all assignments
729745 current_all_assignment = [
730746 c ["target" ].get ("@odata.type" )
731747 for c in current_assignment ["value" ]
732748 if c ["target" ]["@odata.type" ] != "#microsoft.graph.groupAssignmentTarget"
733749 ]
734750
735- # Convert human readable All Users and All Devices to the odata type
736- for assignment in assignment_info :
737- if assignment .get ("all_assignment" ) == "AllUsers" :
738- assignment ["all_assignment" ] = (
739- "#microsoft.graph.allLicensedUsersAssignmentTarget"
740- )
741- elif assignment .get ("all_assignment" ) == "AllDevices" :
742- assignment ["all_assignment" ] = (
743- "#microsoft.graph.allDevicesAssignmentTarget"
744- )
751+ normalized = self ._normalize_assignment_info (assignment_info )
745752
746- # Check if the group id is not in the current assignments
747753 missing_assignment = [
748- a
749- for a in assignment_info
750- if "group_id" in a and a ["group_id" ] not in current_group_ids
754+ a for a in normalized if a ["group_id" ] is not None and a ["group_id" ] not in current_group_ids
751755 ]
752- # Check if there are missing all assignments
753756 missing_all_assignment = [
754- a
755- for a in assignment_info
756- if "all_assignment" in a
757- and a ["all_assignment" ] not in current_all_assignment
757+ a for a in normalized if a ["group_id" ] is None and a ["odata_type" ] not in current_all_assignment
758758 ]
759759 data = {"mobileAppAssignments" : []}
760760
761- if missing_assignment :
762- for assignment in missing_assignment :
763- # Assign the app to the group
764- if assignment .get ("exclude" ) is True :
765- odata_type = "#microsoft.graph.exclusionGroupAssignmentTarget"
766- else :
767- odata_type = "#microsoft.graph.groupAssignmentTarget"
768- data ["mobileAppAssignments" ].append (
769- {
770- "@odata.type" : "#microsoft.graph.mobileAppAssignment" ,
771- "target" : {
772- "@odata.type" : odata_type ,
773- "groupId" : assignment ["group_id" ],
774- },
775- "intent" : assignment ["intent" ],
776- "settings" : None ,
777- }
778- )
761+ for a in missing_assignment :
762+ data ["mobileAppAssignments" ].append (
763+ {
764+ "@odata.type" : "#microsoft.graph.mobileAppAssignment" ,
765+ "target" : {
766+ "@odata.type" : a ["odata_type" ],
767+ "groupId" : a ["group_id" ],
768+ },
769+ "intent" : a ["intent" ],
770+ "settings" : None ,
771+ }
772+ )
779773
780- if missing_all_assignment :
781- for assignment in missing_all_assignment :
782- data ["mobileAppAssignments" ].append (
783- {
784- "@odata.type" : "#microsoft.graph.mobileAppAssignment" ,
785- "target" : {
786- "@odata.type" : assignment ["all_assignment" ],
787- },
788- "intent" : assignment ["intent" ],
789- "settings" : None ,
790- }
791- )
774+ for a in missing_all_assignment :
775+ data ["mobileAppAssignments" ].append (
776+ {
777+ "@odata.type" : "#microsoft.graph.mobileAppAssignment" ,
778+ "target" : {
779+ "@odata.type" : a ["odata_type" ],
780+ },
781+ "intent" : a ["intent" ],
782+ "settings" : None ,
783+ }
784+ )
792785
793786 for assignment in current_assignment ["value" ]:
794787 data ["mobileAppAssignments" ].append (
@@ -812,6 +805,114 @@ def assign_app(self, app, assignment_info: dict) -> None:
812805 200 ,
813806 )
814807
808+ _ALL_ASSIGNMENT_MAP = {
809+ "AllUsers" : "#microsoft.graph.allLicensedUsersAssignmentTarget" ,
810+ "AllDevices" : "#microsoft.graph.allDevicesAssignmentTarget" ,
811+ }
812+
813+ def _normalize_assignment_info (self , assignment_info ):
814+ """Normalize recipe-format assignment_info into (odata_type, group_id, intent) dicts."""
815+ result = []
816+ for a in assignment_info :
817+ intent = a .get ("intent" , "" ).lower ()
818+ if "all_assignment" in a :
819+ odata_type = self ._ALL_ASSIGNMENT_MAP .get (
820+ a ["all_assignment" ], a ["all_assignment" ]
821+ )
822+ result .append ({"odata_type" : odata_type , "group_id" : None , "intent" : intent })
823+ elif "group_id" in a :
824+ if a .get ("exclude" ):
825+ odata_type = "#microsoft.graph.exclusionGroupAssignmentTarget"
826+ else :
827+ odata_type = "#microsoft.graph.groupAssignmentTarget"
828+ result .append ({"odata_type" : odata_type , "group_id" : a ["group_id" ], "intent" : intent })
829+ return result
830+
831+ def _normalize_current_assignments (self , assignments ):
832+ result = []
833+ for a in assignments :
834+ target = a .get ("target" , {})
835+ result .append ({
836+ "odata_type" : target .get ("@odata.type" , "" ),
837+ "group_id" : target .get ("groupId" ),
838+ "intent" : a .get ("intent" , "" ).lower (),
839+ })
840+ return result
841+
842+ _ODATA_TYPE_LABELS = {
843+ "#microsoft.graph.allLicensedUsersAssignmentTarget" : "AllUsers" ,
844+ "#microsoft.graph.allDevicesAssignmentTarget" : "AllDevices" ,
845+ "#microsoft.graph.groupAssignmentTarget" : "group" ,
846+ "#microsoft.graph.exclusionGroupAssignmentTarget" : "exclude" ,
847+ }
848+
849+ def _assignment_label (self , a ):
850+ type_label = self ._ODATA_TYPE_LABELS .get (a ["odata_type" ], a ["odata_type" ])
851+ target = f":{ a ['group_id' ]} " if a ["group_id" ] else ""
852+ return f"{ a ['intent' ]} ({ type_label } { target } )"
853+
854+ def sync_app_assignments (self , assignment_info , diff : bool = False ) -> None :
855+ """Syncs app assignments to exactly match assignment_info via a full replace.
856+
857+ When diff=True, reads current assignments first and skips the POST if already
858+ in sync, logging what would be added/removed. Default is False (unconditional
859+ POST, no extra read).
860+ """
861+ for a in assignment_info :
862+ if "exclude" not in a :
863+ a ["exclude" ] = False
864+
865+ desired = self ._normalize_assignment_info (assignment_info )
866+ display_name = self .request .get ("displayName" , "" )
867+
868+ if diff :
869+ current_raw = self .makeapirequest (
870+ f"{ self .BASE_ENDPOINT } /{ self .request ['id' ]} /assignments" ,
871+ self .token ,
872+ )
873+ current = self ._normalize_current_assignments (current_raw ["value" ])
874+
875+ def key (a ):
876+ return (a ["odata_type" ], a ["group_id" ] or "" , a ["intent" ])
877+
878+ current_keys = {key (a ): a for a in current }
879+ desired_keys = {key (a ): a for a in desired }
880+
881+ if current_keys == desired_keys :
882+ self .output (f"{ display_name } assignments already in sync, skipping." )
883+ return
884+
885+ added = [self ._assignment_label (a ) for k , a in desired_keys .items () if k not in current_keys ]
886+ removed = [self ._assignment_label (a ) for k , a in current_keys .items () if k not in desired_keys ]
887+ parts = []
888+ if added :
889+ parts .append (f"added: { ', ' .join (added )} " )
890+ if removed :
891+ parts .append (f"removed: { ', ' .join (removed )} " )
892+ self .output (f"{ display_name } syncing assignments — { '; ' .join (parts )} " )
893+
894+ assignments = []
895+ for a in desired :
896+ target = {"@odata.type" : a ["odata_type" ]}
897+ if a ["group_id" ]:
898+ target ["groupId" ] = a ["group_id" ]
899+ assignments .append ({
900+ "@odata.type" : "#microsoft.graph.mobileAppAssignment" ,
901+ "target" : target ,
902+ "intent" : a ["intent" ],
903+ "settings" : None ,
904+ })
905+
906+ self .makeapirequestPost (
907+ f"{ self .BASE_ENDPOINT } /{ self .request ['id' ]} /assign" ,
908+ self .token ,
909+ "" ,
910+ json .dumps ({"mobileAppAssignments" : assignments }),
911+ 200 ,
912+ )
913+ if not diff :
914+ self .output (f"{ display_name } synced assignments" )
915+
815916
816917if __name__ == "__main__" :
817918 PROCESSOR = IntuneUploaderBase ()
0 commit comments