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:
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