feat(04-05): implement BulkSiteService with PnP Framework site creation
- BulkSiteService creates Team sites via TeamSiteCollectionCreationInformation with owners/members - BulkSiteService creates Communication sites via CommunicationSiteCollectionCreationInformation with generated URL - Per-site error handling via BulkOperationRunner with continue-on-error semantics - SanitizeAlias generates URL-safe aliases from site names for Communication sites - BulkSiteServiceTests: 3 pass (interface check + model defaults + CSV parsing), 3 skip (live SP) - Fixed pre-existing BulkMemberService.cs Group type ambiguity (MSCSC.Group vs Graph.Models.Group)
This commit is contained in:
56
SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs
Normal file
56
SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using SharepointToolbox.Services;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Tests.Services;
|
||||||
|
|
||||||
|
public class BulkSiteServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BulkSiteService_Implements_IBulkSiteService()
|
||||||
|
{
|
||||||
|
Assert.True(typeof(IBulkSiteService).IsAssignableFrom(typeof(BulkSiteService)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BulkSiteRow_DefaultValues()
|
||||||
|
{
|
||||||
|
var row = new BulkSiteRow();
|
||||||
|
Assert.Equal(string.Empty, row.Name);
|
||||||
|
Assert.Equal(string.Empty, row.Alias);
|
||||||
|
Assert.Equal(string.Empty, row.Type);
|
||||||
|
Assert.Equal(string.Empty, row.Template);
|
||||||
|
Assert.Equal(string.Empty, row.Owners);
|
||||||
|
Assert.Equal(string.Empty, row.Members);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BulkSiteRow_ParsesCommaSeparatedEmails()
|
||||||
|
{
|
||||||
|
var row = new BulkSiteRow
|
||||||
|
{
|
||||||
|
Name = "Test Site",
|
||||||
|
Alias = "test-site",
|
||||||
|
Type = "Team",
|
||||||
|
Owners = "admin@test.com, user@test.com",
|
||||||
|
Members = "member1@test.com,member2@test.com"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Equal("Test Site", row.Name);
|
||||||
|
Assert.Contains("admin@test.com", row.Owners);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint admin context")]
|
||||||
|
public async Task CreateSitesAsync_TeamSite_CreatesWithOwners()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint admin context")]
|
||||||
|
public async Task CreateSitesAsync_CommunicationSite_CreatesWithUrl()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact(Skip = "Requires live SharePoint admin context")]
|
||||||
|
public async Task CreateSitesAsync_MixedTypes_HandlesEachCorrectly()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
187
SharepointToolbox/Services/BulkMemberService.cs
Normal file
187
SharepointToolbox/Services/BulkMemberService.cs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
using Microsoft.Graph;
|
||||||
|
using Microsoft.Graph.Models;
|
||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using Serilog;
|
||||||
|
using SharepointToolbox.Core.Helpers;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
using AuthGraphClientFactory = SharepointToolbox.Infrastructure.Auth.GraphClientFactory;
|
||||||
|
using SpGroup = Microsoft.SharePoint.Client.Group;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public class BulkMemberService : IBulkMemberService
|
||||||
|
{
|
||||||
|
private readonly AuthGraphClientFactory _graphClientFactory;
|
||||||
|
|
||||||
|
public BulkMemberService(AuthGraphClientFactory graphClientFactory)
|
||||||
|
{
|
||||||
|
_graphClientFactory = graphClientFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BulkOperationSummary<BulkMemberRow>> AddMembersAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string clientId,
|
||||||
|
IReadOnlyList<BulkMemberRow> rows,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await BulkOperationRunner.RunAsync(
|
||||||
|
rows,
|
||||||
|
async (row, idx, token) =>
|
||||||
|
{
|
||||||
|
await AddSingleMemberAsync(ctx, clientId, row, progress, token);
|
||||||
|
},
|
||||||
|
progress,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddSingleMemberAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string clientId,
|
||||||
|
BulkMemberRow row,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var siteUrl = row.GroupUrl;
|
||||||
|
if (string.IsNullOrWhiteSpace(siteUrl))
|
||||||
|
{
|
||||||
|
// Fallback: use the context URL + group name for classic SP group
|
||||||
|
await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try Graph API first for M365 Groups
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var graphClient = await _graphClientFactory.CreateClientAsync(clientId, ct);
|
||||||
|
|
||||||
|
// Resolve the group ID from the site URL
|
||||||
|
var groupId = await ResolveGroupIdAsync(graphClient, siteUrl, ct);
|
||||||
|
if (groupId != null)
|
||||||
|
{
|
||||||
|
await AddViaGraphAsync(graphClient, groupId, row.Email, row.Role, ct);
|
||||||
|
Log.Information("Added {Email} to M365 group {Group} via Graph", row.Email, row.GroupName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Graph API failed for {GroupUrl}, falling back to CSOM: {Error}",
|
||||||
|
siteUrl, ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSOM fallback for classic SharePoint groups
|
||||||
|
await AddToClassicGroupAsync(ctx, row.GroupName, row.Email, row.Role, progress, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddViaGraphAsync(
|
||||||
|
GraphServiceClient graphClient,
|
||||||
|
string groupId,
|
||||||
|
string email,
|
||||||
|
string role,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Resolve user by email
|
||||||
|
var user = await graphClient.Users[email].GetAsync(cancellationToken: ct);
|
||||||
|
if (user == null)
|
||||||
|
throw new InvalidOperationException($"User not found: {email}");
|
||||||
|
|
||||||
|
var userRef = $"https://graph.microsoft.com/v1.0/directoryObjects/{user.Id}";
|
||||||
|
|
||||||
|
if (role.Equals("Owner", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var body = new ReferenceCreate { OdataId = userRef };
|
||||||
|
await graphClient.Groups[groupId].Owners.Ref.PostAsync(body, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var body = new ReferenceCreate { OdataId = userRef };
|
||||||
|
await graphClient.Groups[groupId].Members.Ref.PostAsync(body, cancellationToken: ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string?> ResolveGroupIdAsync(
|
||||||
|
GraphServiceClient graphClient,
|
||||||
|
string siteUrl,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Parse site URL to get hostname and site path
|
||||||
|
var uri = new Uri(siteUrl);
|
||||||
|
var hostname = uri.Host;
|
||||||
|
var sitePath = uri.AbsolutePath.TrimEnd('/');
|
||||||
|
|
||||||
|
var site = await graphClient.Sites[$"{hostname}:{sitePath}"].GetAsync(cancellationToken: ct);
|
||||||
|
if (site?.Id == null) return null;
|
||||||
|
|
||||||
|
// Try to get the associated group
|
||||||
|
// Site.Id format: "hostname,siteCollectionId,siteId"
|
||||||
|
var parts = site.Id.Split(',');
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var groups = await graphClient.Groups
|
||||||
|
.GetAsync(r =>
|
||||||
|
{
|
||||||
|
r.QueryParameters.Filter = $"resourceProvisioningOptions/any(x:x eq 'Team')";
|
||||||
|
r.QueryParameters.Select = new[] { "id", "displayName", "resourceProvisioningOptions" };
|
||||||
|
}, cancellationToken: ct);
|
||||||
|
|
||||||
|
// Find group associated with this site
|
||||||
|
if (groups?.Value != null)
|
||||||
|
{
|
||||||
|
foreach (var group in groups.Value)
|
||||||
|
{
|
||||||
|
if (group.Id != null) return group.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* not a group-connected site */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task AddToClassicGroupAsync(
|
||||||
|
ClientContext ctx,
|
||||||
|
string groupName,
|
||||||
|
string email,
|
||||||
|
string role,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var web = ctx.Web;
|
||||||
|
var groups = web.SiteGroups;
|
||||||
|
ctx.Load(groups);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
Microsoft.SharePoint.Client.Group? targetGroup = null;
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
if (group.Title.Equals(groupName, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
targetGroup = group;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetGroup == null)
|
||||||
|
throw new InvalidOperationException($"SharePoint group not found: {groupName}");
|
||||||
|
|
||||||
|
var user = web.EnsureUser(email);
|
||||||
|
ctx.Load(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
targetGroup.Users.AddUser(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
||||||
|
|
||||||
|
Log.Information("Added {Email} to classic SP group {Group}", email, groupName);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
SharepointToolbox/Services/BulkSiteService.cs
Normal file
194
SharepointToolbox/Services/BulkSiteService.cs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using PnP.Framework.Sites;
|
||||||
|
using Serilog;
|
||||||
|
using SharepointToolbox.Core.Helpers;
|
||||||
|
using SharepointToolbox.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Services;
|
||||||
|
|
||||||
|
public class BulkSiteService : IBulkSiteService
|
||||||
|
{
|
||||||
|
public async Task<BulkOperationSummary<BulkSiteRow>> CreateSitesAsync(
|
||||||
|
ClientContext adminCtx,
|
||||||
|
IReadOnlyList<BulkSiteRow> rows,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await BulkOperationRunner.RunAsync(
|
||||||
|
rows,
|
||||||
|
async (row, idx, token) =>
|
||||||
|
{
|
||||||
|
var siteUrl = await CreateSingleSiteAsync(adminCtx, row, progress, token);
|
||||||
|
Log.Information("Created site: {Name} ({Type}) at {Url}", row.Name, row.Type, siteUrl);
|
||||||
|
},
|
||||||
|
progress,
|
||||||
|
ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> CreateSingleSiteAsync(
|
||||||
|
ClientContext adminCtx,
|
||||||
|
BulkSiteRow row,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (row.Type.Equals("Team", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return await CreateTeamSiteAsync(adminCtx, row, progress, ct);
|
||||||
|
}
|
||||||
|
else if (row.Type.Equals("Communication", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return await CreateCommunicationSiteAsync(adminCtx, row, progress, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Unknown site type: {row.Type}. Expected 'Team' or 'Communication'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> CreateTeamSiteAsync(
|
||||||
|
ClientContext adminCtx,
|
||||||
|
BulkSiteRow row,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var owners = ParseEmails(row.Owners);
|
||||||
|
var members = ParseEmails(row.Members);
|
||||||
|
|
||||||
|
var creationInfo = new TeamSiteCollectionCreationInformation
|
||||||
|
{
|
||||||
|
DisplayName = row.Name,
|
||||||
|
Alias = row.Alias,
|
||||||
|
Description = string.Empty,
|
||||||
|
IsPublic = false,
|
||||||
|
Owners = owners.ToArray(),
|
||||||
|
};
|
||||||
|
|
||||||
|
progress.Report(new OperationProgress(0, 0, $"Creating Team site: {row.Name}..."));
|
||||||
|
|
||||||
|
using var siteCtx = await adminCtx.CreateSiteAsync(creationInfo);
|
||||||
|
siteCtx.Load(siteCtx.Web, w => w.Url);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
|
||||||
|
var siteUrl = siteCtx.Web.Url;
|
||||||
|
|
||||||
|
// Add additional members if specified
|
||||||
|
if (members.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var memberEmail in members)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = siteCtx.Web.EnsureUser(memberEmail);
|
||||||
|
siteCtx.Load(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
|
||||||
|
// Add to Members group
|
||||||
|
var membersGroup = siteCtx.Web.AssociatedMemberGroup;
|
||||||
|
membersGroup.Users.AddUser(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
||||||
|
memberEmail, row.Name, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return siteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> CreateCommunicationSiteAsync(
|
||||||
|
ClientContext adminCtx,
|
||||||
|
BulkSiteRow row,
|
||||||
|
IProgress<OperationProgress> progress,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Build the site URL from alias or sanitized name
|
||||||
|
var alias = !string.IsNullOrWhiteSpace(row.Alias) ? row.Alias : SanitizeAlias(row.Name);
|
||||||
|
var tenantUrl = new Uri(adminCtx.Url);
|
||||||
|
var siteUrl = $"https://{tenantUrl.Host}/sites/{alias}";
|
||||||
|
|
||||||
|
var creationInfo = new CommunicationSiteCollectionCreationInformation
|
||||||
|
{
|
||||||
|
Title = row.Name,
|
||||||
|
Url = siteUrl,
|
||||||
|
Description = string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
progress.Report(new OperationProgress(0, 0, $"Creating Communication site: {row.Name}..."));
|
||||||
|
|
||||||
|
using var siteCtx = await adminCtx.CreateSiteAsync(creationInfo);
|
||||||
|
siteCtx.Load(siteCtx.Web, w => w.Url);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
|
||||||
|
var createdUrl = siteCtx.Web.Url;
|
||||||
|
|
||||||
|
// Add owners and members if specified
|
||||||
|
var owners = ParseEmails(row.Owners);
|
||||||
|
var members = ParseEmails(row.Members);
|
||||||
|
|
||||||
|
foreach (var ownerEmail in owners)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = siteCtx.Web.EnsureUser(ownerEmail);
|
||||||
|
siteCtx.Load(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
|
||||||
|
var ownersGroup = siteCtx.Web.AssociatedOwnerGroup;
|
||||||
|
ownersGroup.Users.AddUser(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Failed to add owner {Email} to {Site}: {Error}",
|
||||||
|
ownerEmail, row.Name, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var memberEmail in members)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var user = siteCtx.Web.EnsureUser(memberEmail);
|
||||||
|
siteCtx.Load(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
|
||||||
|
var membersGroup = siteCtx.Web.AssociatedMemberGroup;
|
||||||
|
membersGroup.Users.AddUser(user);
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning("Failed to add member {Email} to {Site}: {Error}",
|
||||||
|
memberEmail, row.Name, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseEmails(string commaSeparated)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(commaSeparated))
|
||||||
|
return new List<string>();
|
||||||
|
|
||||||
|
return commaSeparated
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(e => !string.IsNullOrWhiteSpace(e))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SanitizeAlias(string name)
|
||||||
|
{
|
||||||
|
// Remove special characters, spaces -> dashes, lowercase
|
||||||
|
var sanitized = new string(name
|
||||||
|
.Where(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-')
|
||||||
|
.ToArray());
|
||||||
|
return sanitized.Replace(' ', '-').ToLowerInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user