diff --git a/CHANGELOG.md b/CHANGELOG.md index de71694bde..2b13af3257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - Added `Add-PnPTenantSearchCrawledProperty` cmdlet which allows creation of a tenant level crawled property for SharePoint Online search. [#5373](https://github.com/pnp/powershell/pull/5373) - Added `GitLab CI/CD` support to `Connect-PnPOnline -FederatedIdentity`. [#5395](https://github.com/pnp/powershell/pull/5395) - Added `PreferredDataLocation` support to `Set-PnPMicrosoft365Group` and documented it for `Get-PnPMicrosoft365Group` and `New-PnPMicrosoft365Group`. [#5402](https://github.com/pnp/powershell/pull/5402) +- Added `Standard` and `EDU_Staff` template support to `New-PnPTeamsTeam` and provision Teams templates through Microsoft Graph team templates. Note that `EDU_Class` and `EDU_PLC` are now also provisioned through Microsoft Graph team templates (`POST /teams`) instead of the previous Microsoft 365 Group creation and teamify flow. [#999](https://github.com/pnp/powershell/issues/999) ### Changed diff --git a/documentation/New-PnPTeamsTeam.md b/documentation/New-PnPTeamsTeam.md index cc81571b93..a9c1520a2b 100644 --- a/documentation/New-PnPTeamsTeam.md +++ b/documentation/New-PnPTeamsTeam.md @@ -14,6 +14,7 @@ online version: https://pnp.github.io/powershell/cmdlets/New-PnPTeamsTeam.html **Required Permissions** * Microsoft Graph API: Group.ReadWrite.All + * Microsoft Graph API: Team.Create when using -Template Creates a new team in Microsoft Teams or teamifies an existing Microsoft 365 Group. If the Microsoft 365 Group does not exist yet, it will create it first and then add a Microsoft Teams team to the group. If it does already exist, it will use the provided Microsoft 365 Group and just teamify it by adding a Microsoft Teams team to it. @@ -46,7 +47,8 @@ New-PnPTeamsTeam -DisplayName [-MailNickName ] [-Description ] [-Members ] [-ResourceBehaviorOptions ] [-SensitivityLabels ] - + [-Template ] + ``` ## DESCRIPTION @@ -98,6 +100,34 @@ New-PnPTeamsTeam -DisplayName "myPnPDemo1" -Visibility Private -Owners "user1@co This will create a new Microsoft Teams team called "myPnPDemo1" and sets the privacy to Private. User1 and user2 will be added as owners. User3 will be added as a member. The team will also get the sensitivity label value corresponding to the GUID specified. +### EXAMPLE 7 +```powershell +New-PnPTeamsTeam -DisplayName "Staff Team" -Visibility Private -Template EDU_Staff -Owners "principal@contoso.onmicrosoft.com" +``` + +This will create a new Microsoft Teams education staff team called "Staff Team" and adds the provided user as owner. Owners are required when using application permissions. + +### EXAMPLE 8 +```powershell +New-PnPTeamsTeam -DisplayName "Biology 101" -Template EDU_Class -Owners "teacher@contoso.onmicrosoft.com" -Members "student@contoso.onmicrosoft.com" +``` + +This will create a new Microsoft Teams education class team called "Biology 101". Microsoft Graph sets class team visibility to HiddenMembership. + +### EXAMPLE 9 +```powershell +New-PnPTeamsTeam -DisplayName "Math PLC" -Visibility Private -Template EDU_PLC -Owners "lead@contoso.onmicrosoft.com" +``` + +This will create a new Microsoft Teams education professional learning community team called "Math PLC". + +### EXAMPLE 10 +```powershell +New-PnPTeamsTeam -DisplayName "Project Team" -Visibility Private -Template Standard -Owners "owner@contoso.onmicrosoft.com" +``` + +This will create a new standard Microsoft Teams team through Microsoft Graph team templates. Omitting Template keeps the existing Microsoft 365 Group creation and teamify behavior. + ## PARAMETERS ### -AllowAddRemoveApps @@ -381,7 +411,7 @@ Accept wildcard characters: False ``` ### -MailNickName -The MailNickName parameter specifies the alias for the associated Microsoft 365 Group. This value will be used for the mail enabled object and will be used as PrimarySmtpAddress for this Microsoft 365 Group.The value of the MailNickName parameter has to be unique across your tenant. +The MailNickName parameter specifies the alias for the associated Microsoft 365 Group. This value will be used for the mail enabled object and will be used as PrimarySmtpAddress for this Microsoft 365 Group.The value of the MailNickName parameter has to be unique across your tenant. This parameter is ignored when used together with Template because Microsoft Graph creates the backing group. ```yaml Type: String @@ -410,6 +440,20 @@ Accept pipeline input: False Accept wildcard characters: False ``` +### -Template +The Microsoft Teams template to use for the new team. Use Standard for the standard team template, EDU_Class for the education class template, EDU_PLC for the education professional learning community template, or EDU_Staff for the education staff template. When this parameter is used, Microsoft Graph provisions the team using POST /teams with a teamsTemplates binding and creates the backing Microsoft 365 Group automatically. Owners are required when using application permissions. Owners and Members are added to the initial members collection when provisioning the team; Microsoft Graph enforces its own limit on the number of users allowed in that collection. Omit this parameter to keep the existing Microsoft 365 Group creation and teamify behavior. + +```yaml +Type: TeamsTemplateType +Parameter Sets: For a new group +Accepted values: None, Standard, EDU_Class, EDU_PLC, EDU_Staff +Required: False +Position: Named +Default value: None +Accept pipeline input: False +Accept wildcard characters: False +``` + ### -Visibility Set to Public to allow all users in your organization to join the group by default. Set to Private to require that an owner approve the join request. @@ -440,7 +484,7 @@ Accept wildcard characters: False ``` ### -Owners -The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as owners. If omitted and the cmdlet is run using a token containing a user identity, such as when logging on with -Interactive or -DeviceLogin, the user used to authenticate with would become the owner. You can provide as many owners as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. +The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as owners. If omitted and the cmdlet is run using a token containing a user identity, such as when logging on with -Interactive or -DeviceLogin, the user used to authenticate with would become the owner. You can provide as many owners as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Owners and Members are added to the initial members collection and Microsoft Graph enforces its own limit on the number of users allowed in that collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. ```yaml Type: String[] @@ -454,7 +498,7 @@ Accept wildcard characters: False ``` ### -Members -The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as members. You can provide as many members as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. +The User Principal Name(s) of the user(s) to be added to the Microsoft 365 Group as members. You can provide as many members as you want, as long as you stay within the [Microsoft 365 Groups limits](https://learn.microsoft.com/microsoft-365/admin/create-groups/office-365-groups?view=o365-worldwide#group-limits). When using Template, Owners and Members are added to the initial members collection and Microsoft Graph enforces its own limit on the number of users allowed in that collection. Notice that e-mail addresses are not accepted, if they differ from the User Principal Name on the same account. ```yaml Type: String[] @@ -468,7 +512,7 @@ Accept wildcard characters: False ### -ResourceBehaviorOptions -Allows providing ResourceBehaviorOptions which accepts multiple values that specify group behaviors for a Microsoft 365 Group. This will only work when you create a new Microsoft 365 Group, it will not work for existing groups. +Allows providing ResourceBehaviorOptions which accepts multiple values that specify group behaviors for a Microsoft 365 Group. This will only work when you create a new Microsoft 365 Group, it will not work for existing groups. This parameter cannot be used together with Template. ```yaml Type: TeamResourceBehaviorOptions @@ -483,7 +527,7 @@ Accept wildcard characters: False ``` ### -SensitivityLabels -The Sensitivity label to be set to the Microsoft 365 Group and Team. To retrieve the sensitivity label you need to use the Graph API mentioned [here](https://learn.microsoft.com/en-us/graph/api/informationprotectionlabel-get?view=graph-rest-beta&tabs=http). +The Sensitivity label to be set to the Microsoft 365 Group and Team. To retrieve the sensitivity label you need to use the Graph API mentioned [here](https://learn.microsoft.com/en-us/graph/api/informationprotectionlabel-get?view=graph-rest-beta&tabs=http). This parameter cannot be used together with Template. ```yaml Type: GUID[] @@ -498,4 +542,3 @@ Accept wildcard characters: False ## RELATED LINKS [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) - diff --git a/src/Commands/Enums/TeamsTemplateType.cs b/src/Commands/Enums/TeamsTemplateType.cs index 23932e13b3..b8575bf43b 100644 --- a/src/Commands/Enums/TeamsTemplateType.cs +++ b/src/Commands/Enums/TeamsTemplateType.cs @@ -6,5 +6,7 @@ public enum TeamsTemplateType None, EDU_Class, EDU_PLC, + EDU_Staff, + Standard } } \ No newline at end of file diff --git a/src/Commands/Model/Graph/TeamsAsyncOperation.cs b/src/Commands/Model/Graph/TeamsAsyncOperation.cs new file mode 100644 index 0000000000..b4da14f51e --- /dev/null +++ b/src/Commands/Model/Graph/TeamsAsyncOperation.cs @@ -0,0 +1,49 @@ +namespace PnP.PowerShell.Commands.Model.Graph +{ + /// + /// Represents a Microsoft Teams async operation returned by Microsoft Graph. + /// + public class TeamsAsyncOperation + { + /// + /// Unique identifier of the operation. + /// + public string Id { get; set; } + + /// + /// Current status of the operation. + /// + public string Status { get; set; } + + /// + /// Identifier of the team that was created or modified by the operation. + /// + public string TargetResourceId { get; set; } + + /// + /// Location of the team that was created or modified by the operation. + /// + public string TargetResourceLocation { get; set; } + + /// + /// Error details returned when the operation fails. + /// + public TeamsAsyncOperationError Error { get; set; } + } + + /// + /// Represents Teams async operation error details returned by Microsoft Graph. + /// + public class TeamsAsyncOperationError + { + /// + /// Error code returned by Microsoft Graph. + /// + public string Code { get; set; } + + /// + /// Error message returned by Microsoft Graph. + /// + public string Message { get; set; } + } +} diff --git a/src/Commands/Model/Teams/TeamSettings.cs b/src/Commands/Model/Teams/TeamSettings.cs index 6b996bd51e..89a2e19b18 100644 --- a/src/Commands/Model/Teams/TeamSettings.cs +++ b/src/Commands/Model/Teams/TeamSettings.cs @@ -23,6 +23,9 @@ public class TeamCreationInformation [JsonConverter(typeof(JsonStringEnumConverter))] public TeamGiphyContentRating GiphyContentRating { get; set; } + [JsonIgnore] + public bool GiphyContentRatingSpecified { get; set; } + public bool? AllowStickersAndMemes { get; set; } public bool? AllowCustomMemes { get; set; } @@ -69,6 +72,7 @@ public TeamCreationInformation(Team team) Classification = team.Classification; AllowGiphy = team.FunSettings.AllowGiphy; GiphyContentRating = team.FunSettings.GiphyContentRating; + GiphyContentRatingSpecified = true; AllowStickersAndMemes = team.FunSettings.AllowStickersAndMemes; AllowCustomMemes = team.FunSettings.AllowCustomMemes; AllowGuestCreateUpdateChannels = team.GuestSettings.AllowCreateUpdateChannels; diff --git a/src/Commands/Teams/NewTeamsTeam.cs b/src/Commands/Teams/NewTeamsTeam.cs index 90ffffde0b..11fbeb7472 100644 --- a/src/Commands/Teams/NewTeamsTeam.cs +++ b/src/Commands/Teams/NewTeamsTeam.cs @@ -8,11 +8,13 @@ using PnP.PowerShell.Commands.Utilities; using System; using System.Management.Automation; +using ConnectionMethod = PnP.PowerShell.Commands.Model.ConnectionMethod; namespace PnP.PowerShell.Commands.Teams { [Cmdlet(VerbsCommon.New, "PnPTeamsTeam")] [RequiredApiDelegatedOrApplicationPermissions("graph/Group.ReadWrite.All")] + [RequiredApiDelegatedOrApplicationPermissions("graph/Team.Create")] public class NewTeamsTeam : PnPGraphCmdlet { private const string ParameterSet_EXISTINGGROUP = "For an existing group"; @@ -109,6 +111,31 @@ public class NewTeamsTeam : PnPGraphCmdlet protected override void ExecuteCmdlet() { + var contextSettings = Connection.Context.GetContextSettings(); + + if (Template != TeamsTemplateType.None) + { + if (ParameterSpecified(nameof(MailNickName))) + { + LogWarning($"{nameof(MailNickName)} is ignored when using {nameof(Template)} because Microsoft Graph creates the backing group when provisioning a team from a template."); + } + + if (ParameterSpecified(nameof(ResourceBehaviorOptions)) || ParameterSpecified(nameof(SensitivityLabels))) + { + throw new PSInvalidOperationException($"{nameof(ResourceBehaviorOptions)} and {nameof(SensitivityLabels)} are not supported when using {nameof(Template)} because Microsoft Graph creates the backing group when provisioning a team from a template."); + } + + if (IsApplicationConnection(Connection.ConnectionMethod) && (Owners == null || Owners.Length == 0)) + { + throw new PSInvalidOperationException($"{nameof(Owners)} is required when using {nameof(Template)} with application permissions because Microsoft Graph requires a user in the members collection when creating a team with application permissions."); + } + + if (Template == TeamsTemplateType.EDU_Class && ParameterSpecified(nameof(Visibility))) + { + LogWarning("The EDU_Class template sets team visibility to HiddenMembership and does not allow overriding it. The Visibility value will be ignored by Microsoft Graph."); + } + } + var teamCI = new TeamCreationInformation() { AllowAddRemoveApps = AllowAddRemoveApps, @@ -130,13 +157,13 @@ protected override void ExecuteCmdlet() Description = Description, DisplayName = DisplayName, GiphyContentRating = GiphyContentRating, + GiphyContentRatingSpecified = ParameterSpecified(nameof(GiphyContentRating)), GroupId = GroupId, ShowInTeamsSearchAndSuggestions = ShowInTeamsSearchAndSuggestions, Visibility = (GroupVisibility)Enum.Parse(typeof(GroupVisibility), Visibility.ToString()), AllowCreatePrivateChannels = AllowCreatePrivateChannels }; - var contextSettings = Connection.Context.GetContextSettings(); if (contextSettings.Type == Framework.Utilities.Context.ClientContextType.AzureADCertificate) { if (SensitivityLabels != null && SensitivityLabels.Length > 0) @@ -148,5 +175,13 @@ protected override void ExecuteCmdlet() WriteObject(TeamsUtility.NewTeam(GraphRequestHelper, GroupId, DisplayName, Description, Classification, MailNickName, (GroupVisibility)Enum.Parse(typeof(GroupVisibility), Visibility.ToString()), teamCI, Owners, Members, SensitivityLabels, Template, ResourceBehaviorOptions)); } + + private static bool IsApplicationConnection(ConnectionMethod connectionMethod) + { + return connectionMethod == ConnectionMethod.AzureADAppOnly || + connectionMethod == ConnectionMethod.ManagedIdentity || + connectionMethod == ConnectionMethod.AzureADWorkloadIdentity || + connectionMethod == ConnectionMethod.FederatedIdentity; + } } } diff --git a/src/Commands/Utilities/TeamsUtility.cs b/src/Commands/Utilities/TeamsUtility.cs index be71d33996..6e56ad2f21 100644 --- a/src/Commands/Utilities/TeamsUtility.cs +++ b/src/Commands/Utilities/TeamsUtility.cs @@ -8,8 +8,10 @@ using System.Collections.Generic; using System.Linq; using System.Management.Automation; +using System.Net; using System.Net.Http; using System.Text.Json; +using System.Text.Json.Serialization; using System.Threading; using Group = PnP.PowerShell.Commands.Model.Graph.Group; using Team = PnP.PowerShell.Commands.Model.Teams.Team; @@ -21,6 +23,10 @@ namespace PnP.PowerShell.Commands.Utilities internal static class TeamsUtility { private const int PageSize = 100; + private const int TeamsAsyncOperationPollingIntervalSeconds = 30; + private const int TeamsAsyncOperationMaxRetries = 12; + private const int TeamsTemplateGetTeamRetryIntervalSeconds = 5; + private const int TeamsTemplateGetTeamMaxRetries = 3; #region Team public static List GetGroupsWithTeam(ApiRequestHelper requestHelper, string filter = null) @@ -147,6 +153,11 @@ private static Team ParseTeamJson(ApiRequestHelper requestHelper, string groupId public static Team NewTeam(ApiRequestHelper requestHelper, string groupId, string displayName, string description, string classification, string mailNickname, GroupVisibility visibility, TeamCreationInformation teamCI, string[] owners, string[] members, Guid[] sensitivityLabels, TeamsTemplateType templateType = TeamsTemplateType.None, TeamResourceBehaviorOptions?[] resourceBehaviorOptions = null) { + if (string.IsNullOrEmpty(groupId) && templateType != TeamsTemplateType.None) + { + return CreateTeamFromTemplate(requestHelper, displayName, description, classification, visibility, teamCI, owners, members, templateType); + } + Group group = null; Team returnTeam = null; Random random = new(); @@ -155,7 +166,7 @@ public static Team NewTeam(ApiRequestHelper requestHelper, string groupId, strin // Create the Group if (string.IsNullOrEmpty(groupId)) { - group = CreateGroup(requestHelper, displayName, description, classification, mailNickname, visibility, owners, sensitivityLabels, templateType, resourceBehaviorOptions); + group = CreateGroup(requestHelper, displayName, description, classification, mailNickname, visibility, owners, sensitivityLabels, resourceBehaviorOptions); bool wait = true; int iterations = 0; @@ -244,34 +255,342 @@ public static Team NewTeam(ApiRequestHelper requestHelper, string groupId, strin } } - // Construct a list of all owners and members to add - var teamOwnersAndMembers = new List(); - if (owners != null && owners.Length > 0) + AddTeamOwnersAndMembers(requestHelper, group.Id, owners, members); + } + return returnTeam; + } + + private static Team CreateTeamFromTemplate(ApiRequestHelper requestHelper, string displayName, string description, string classification, GroupVisibility visibility, TeamCreationInformation teamCI, string[] owners, string[] members, TeamsTemplateType templateType) + { + var teamVisibility = visibility == GroupVisibility.NotSpecified ? GroupVisibility.Private : visibility; + var team = teamCI.ToTeam(teamVisibility); + var payload = new Dictionary + { + { "template@odata.bind", $"https://{requestHelper.GraphEndPoint}/v1.0/teamsTemplates('{GetTeamsTemplateId(templateType)}')" }, + { "displayName", displayName }, + { "description", description ?? string.Empty } + }; + + if (!string.IsNullOrEmpty(classification)) + { + payload.Add("classification", classification); + } + + if (templateType != TeamsTemplateType.EDU_Class) + { + payload.Add("visibility", teamVisibility.ToString()); + } + + if (HasMemberSettings(team.MemberSettings)) + { + payload.Add("memberSettings", team.MemberSettings); + } + + if (HasGuestSettings(team.GuestSettings)) + { + payload.Add("guestSettings", team.GuestSettings); + } + + if (HasMessagingSettings(team.MessagingSettings)) + { + payload.Add("messagingSettings", team.MessagingSettings); + } + + if (HasFunSettings(teamCI)) + { + payload.Add("funSettings", CreateFunSettingsPayload(teamCI)); + } + + if (HasDiscoverySettings(team.DiscoverySettings)) + { + payload.Add("discoverySettings", team.DiscoverySettings); + } + + var teamMembers = CreateInitialTeamMembers(requestHelper, owners, members); + if (teamMembers.Count > 0) + { + payload.Add("members", teamMembers); + } + + string operationStatusUrl; + using (var response = requestHelper.PostHttpContent("v1.0/teams", CreateJsonContent(payload))) + { + operationStatusUrl = GetTeamsAsyncOperationStatusUrl(response); + } + + var operation = WaitForTeamsAsyncOperation(requestHelper, operationStatusUrl); + + if (string.IsNullOrEmpty(operation.TargetResourceId)) + { + throw new PSInvalidOperationException("Microsoft Graph completed the team creation operation without returning a target team identifier."); + } + + // The team was provisioned successfully. Try to return the fully hydrated team from Microsoft Graph so the + // output matches the non-template creation path. Fall back to the requested values if the freshly created + // team cannot be read back yet due to replication delays. + var provisionedTeam = GetProvisionedTeam(requestHelper, operation.TargetResourceId); + if (provisionedTeam != null) + { + return provisionedTeam; + } + + team.GroupId = operation.TargetResourceId; + team.DisplayName = displayName; + team.Description = description; + team.Classification = classification; + team.Visibility = templateType == TeamsTemplateType.EDU_Class ? GroupVisibility.HiddenMembership : teamVisibility; + team.Specialization = GetTeamSpecialization(templateType); + return team; + } + + private static Team GetProvisionedTeam(ApiRequestHelper requestHelper, string groupId) + { + // Right after an async provisioning operation completes, the backing group and team can take a short while + // to become queryable. Retry a few times before giving up and letting the caller fall back to the requested values. + for (var retryCount = 0; retryCount < TeamsTemplateGetTeamMaxRetries; retryCount++) + { + if (retryCount > 0) { - foreach (var owner in owners) - { - teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List { "owner" }, UserIdentifier = $"https://{requestHelper.GraphEndPoint}/v1.0/users('{owner}')" }); - } + Thread.Sleep(TimeSpan.FromSeconds(TeamsTemplateGetTeamRetryIntervalSeconds)); } - if (members != null && members.Length > 0) + try { - foreach (var member in members) + var team = GetTeam(requestHelper, groupId); + if (team != null) { - teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List(), UserIdentifier = $"https://{requestHelper.GraphEndPoint}/v1.0/users('{member}')" }); + return team; } } + catch (GraphException) + { + // The team is not queryable yet, retry. + } + } + + return null; + } + + private static List CreateInitialTeamMembers(ApiRequestHelper requestHelper, string[] owners, string[] members) + { + var teamMembers = new List(); + var ownerIdentifiers = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (owners != null && owners.Length > 0) + { + foreach (var owner in owners.Distinct(StringComparer.OrdinalIgnoreCase)) + { + ownerIdentifiers.Add(owner); + teamMembers.Add(new TeamChannelMember { Roles = new List { "owner" }, UserIdentifier = GetUserODataBindUrl(requestHelper, owner) }); + } + } - if (teamOwnersAndMembers.Count > 0) + if (members != null && members.Length > 0) + { + foreach (var member in members.Distinct(StringComparer.OrdinalIgnoreCase)) { - var ownersAndMembers = GraphBatchUtility.Chunk(teamOwnersAndMembers, 200); - foreach (var chunk in ownersAndMembers) + if (!ownerIdentifiers.Contains(member)) { - requestHelper.Post($"v1.0/teams/{group.Id}/members/add", new { values = chunk.ToList() }); + teamMembers.Add(new TeamChannelMember { Roles = new List(), UserIdentifier = GetUserODataBindUrl(requestHelper, member) }); } } } - return returnTeam; + + return teamMembers; + } + + private static string GetUserODataBindUrl(ApiRequestHelper requestHelper, string user) + { + return $"https://{requestHelper.GraphEndPoint}/v1.0/users('{user}')"; + } + + private static StringContent CreateJsonContent(object payload) + { + var content = new StringContent(JsonSerializer.Serialize(payload, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + return content; + } + + private static string GetTeamsAsyncOperationStatusUrl(HttpResponseMessage response) + { + if (!response.Headers.TryGetValues("Location", out var locations) || !locations.Any()) + { + throw new PSInvalidOperationException("Microsoft Graph did not return a Location header for the team creation operation."); + } + + return locations.First(); + } + + private static TeamsAsyncOperation WaitForTeamsAsyncOperation(ApiRequestHelper requestHelper, string operationStatusUrl) + { + TeamsAsyncOperation operation = null; + GraphException lastTransientException = null; + for (var retryCount = 0; retryCount < TeamsAsyncOperationMaxRetries; retryCount++) + { + if (retryCount > 0) + { + Thread.Sleep(TimeSpan.FromSeconds(TeamsAsyncOperationPollingIntervalSeconds)); + } + + try + { + operation = requestHelper.Get(operationStatusUrl); + } + catch (GraphException ex) when (IsTransientTeamsAsyncOperationError(ex)) + { + // A transient error (including on the final attempt) should not surface as a raw Graph exception. + // Remember it so it can be included in the timeout message if we never get a definitive status. + lastTransientException = ex; + continue; + } + + if (operation == null || string.IsNullOrEmpty(operation.Status)) + { + continue; + } + + if (operation.Status.Equals("succeeded", StringComparison.OrdinalIgnoreCase) || operation.Status.Equals("completed", StringComparison.OrdinalIgnoreCase)) + { + return operation; + } + + if (operation.Status.Equals("failed", StringComparison.OrdinalIgnoreCase)) + { + var errorMessage = !string.IsNullOrEmpty(operation.Error?.Message) ? $": {operation.Error.Message}" : string.Empty; + throw new PSInvalidOperationException($"Microsoft Graph team creation operation failed{errorMessage}"); + } + } + + var timeoutDetail = lastTransientException?.HttpResponse != null + ? $" The last polling attempt failed with status code {(int)lastTransientException.HttpResponse.StatusCode}." + : string.Empty; + throw new PSInvalidOperationException($"Timed out waiting for Microsoft Graph team creation operation at {operationStatusUrl} to complete.{timeoutDetail}"); + } + + private static bool IsTransientTeamsAsyncOperationError(GraphException ex) + { + return ex.HttpResponse?.StatusCode == HttpStatusCode.NotFound || + ex.HttpResponse?.StatusCode == HttpStatusCode.BadGateway || + ex.HttpResponse?.StatusCode == HttpStatusCode.ServiceUnavailable || + ex.HttpResponse?.StatusCode == HttpStatusCode.GatewayTimeout; + } + + private static string GetTeamsTemplateId(TeamsTemplateType templateType) + { + return templateType switch + { + TeamsTemplateType.Standard => "standard", + TeamsTemplateType.EDU_Class => "educationClass", + TeamsTemplateType.EDU_PLC => "educationProfessionalLearningCommunity", + TeamsTemplateType.EDU_Staff => "educationStaff", + _ => "standard" + }; + } + + private static TeamSpecialization GetTeamSpecialization(TeamsTemplateType templateType) + { + return templateType switch + { + TeamsTemplateType.Standard => TeamSpecialization.None, + TeamsTemplateType.EDU_Class => TeamSpecialization.EducationClass, + TeamsTemplateType.EDU_PLC => TeamSpecialization.EducationProfessionalLearningCommunity, + TeamsTemplateType.EDU_Staff => TeamSpecialization.EducationStaff, + _ => TeamSpecialization.None + }; + } + + private static void AddTeamOwnersAndMembers(ApiRequestHelper requestHelper, string groupId, string[] owners, string[] members) + { + var teamOwnersAndMembers = new List(); + if (owners != null && owners.Length > 0) + { + foreach (var owner in owners) + { + teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List { "owner" }, UserIdentifier = GetUserODataBindUrl(requestHelper, owner) }); + } + } + + if (members != null && members.Length > 0) + { + foreach (var member in members) + { + teamOwnersAndMembers.Add(new TeamChannelMember { Roles = new List(), UserIdentifier = GetUserODataBindUrl(requestHelper, member) }); + } + } + + if (teamOwnersAndMembers.Count > 0) + { + var ownersAndMembers = GraphBatchUtility.Chunk(teamOwnersAndMembers, 200); + foreach (var chunk in ownersAndMembers) + { + requestHelper.Post($"v1.0/teams/{groupId}/members/add", new { values = chunk.ToList() }); + } + } + } + + private static bool HasMemberSettings(TeamMemberSettings memberSettings) + { + return memberSettings.AllowCreateUpdateChannels.HasValue || + memberSettings.AllowDeleteChannels.HasValue || + memberSettings.AllowAddRemoveApps.HasValue || + memberSettings.AllowCreateUpdateRemoveTabs.HasValue || + memberSettings.AllowCreateUpdateRemoveConnectors.HasValue || + memberSettings.AllowCreatePrivateChannels.HasValue; + } + + private static bool HasGuestSettings(TeamGuestSettings guestSettings) + { + return guestSettings.AllowCreateUpdateChannels.HasValue || + guestSettings.AllowDeleteChannels.HasValue; + } + + private static bool HasMessagingSettings(TeamMessagingSettings messagingSettings) + { + return messagingSettings.AllowUserEditMessages.HasValue || + messagingSettings.AllowUserDeleteMessages.HasValue || + messagingSettings.AllowOwnerDeleteMessages.HasValue || + messagingSettings.AllowTeamMentions.HasValue || + messagingSettings.AllowChannelMentions.HasValue; + } + + private static bool HasFunSettings(TeamCreationInformation teamCI) + { + return teamCI.AllowGiphy.HasValue || + teamCI.AllowCustomMemes.HasValue || + teamCI.AllowStickersAndMemes.HasValue || + teamCI.GiphyContentRatingSpecified; + } + + private static Dictionary CreateFunSettingsPayload(TeamCreationInformation teamCI) + { + var funSettings = new Dictionary(); + + if (teamCI.AllowGiphy.HasValue) + { + funSettings.Add("allowGiphy", teamCI.AllowGiphy.Value); + } + + if (teamCI.GiphyContentRatingSpecified) + { + funSettings.Add("giphyContentRating", teamCI.GiphyContentRating.ToString()); + } + + if (teamCI.AllowStickersAndMemes.HasValue) + { + funSettings.Add("allowStickersAndMemes", teamCI.AllowStickersAndMemes.Value); + } + + if (teamCI.AllowCustomMemes.HasValue) + { + funSettings.Add("allowCustomMemes", teamCI.AllowCustomMemes.Value); + } + + return funSettings; + } + + private static bool HasDiscoverySettings(TeamDiscoverySettings discoverySettings) + { + return discoverySettings.ShowInTeamsSearchAndSuggestions.HasValue; } internal static string GetUserGraphUrlForUPN(string upn) @@ -284,7 +603,7 @@ internal static string GetUserGraphUrlForUPN(string upn) return $"users/{escapedUpn}"; } - private static Group CreateGroup(ApiRequestHelper requestHelper, string displayName, string description, string classification, string mailNickname, GroupVisibility visibility, string[] owners, Guid[] sensitivityLabels, TeamsTemplateType templateType = TeamsTemplateType.None, TeamResourceBehaviorOptions?[] resourceBehaviorOptions = null) + private static Group CreateGroup(ApiRequestHelper requestHelper, string displayName, string description, string classification, string mailNickname, GroupVisibility visibility, string[] owners, Guid[] sensitivityLabels, TeamResourceBehaviorOptions?[] resourceBehaviorOptions = null) { // When creating a group, we always need an owner, thus we'll try to define it from the passed in owners array string ownerId = null; @@ -376,22 +695,7 @@ private static Group CreateGroup(ApiRequestHelper requestHelper, string displayN group.AssignedLabels = assignedLabels; } - switch (templateType) - { - case TeamsTemplateType.EDU_Class: - group.Visibility = GroupVisibility.HiddenMembership; - group.CreationOptions = new List { "ExchangeProvisioningFlags:461", "classAssignments" }; - group.EducationObjectType = "Section"; - break; - - case TeamsTemplateType.EDU_PLC: - group.CreationOptions = new List { "PLC" }; - break; - - default: - group.CreationOptions = new List { "ExchangeProvisioningFlags:3552" }; - break; - } + group.CreationOptions = new List { "ExchangeProvisioningFlags:3552" }; try { return requestHelper.Post("v1.0/groups", group);