docs(04-05): complete BulkSiteService plan — PnP Framework Team + Communication site creation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev
2026-04-03 10:04:03 +02:00
parent f3a1c352c7
commit 98fa16a195
5 changed files with 513 additions and 8 deletions

View File

@@ -69,7 +69,7 @@ Requirements for initial release. Each maps to roadmap phases.
- [ ] **BULK-01**: User can transfer files and folders between sites with progress tracking
- [ ] **BULK-02**: User can add members to groups in bulk from CSV
- [ ] **BULK-03**: User can create multiple sites in bulk from CSV
- [x] **BULK-03**: User can create multiple sites in bulk from CSV
- [x] **BULK-04**: All bulk operations support cancellation mid-execution
- [x] **BULK-05**: Bulk operation errors are reported per-item (not silently skipped)
@@ -154,7 +154,7 @@ Which phases cover which requirements. Updated during roadmap creation.
| FOLD-02 | Phase 4 | Pending |
| BULK-01 | Phase 4 | Pending |
| BULK-02 | Phase 4 | Pending |
| BULK-03 | Phase 4 | Pending |
| BULK-03 | Phase 4 | Complete |
| BULK-04 | Phase 4 | Complete |
| BULK-05 | Phase 4 | Complete |

View File

@@ -138,5 +138,5 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5
| 1. Foundation | 8/8 | Complete | 2026-04-02 |
| 2. Permissions | 7/7 | Complete | 2026-04-02 |
| 3. Storage and File Operations | 8/8 | Complete | 2026-04-02 |
| 4. Bulk Operations and Provisioning | 1/10 | In Progress| |
| 4. Bulk Operations and Provisioning | 2/10 | In Progress| |
| 5. Distribution and Hardening | 0/? | Not started | - |

View File

@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: planning
stopped_at: Completed 04-bulk-operations-and-provisioning-04-01-PLAN.md
last_updated: "2026-04-03T07:55:04.919Z"
stopped_at: Completed 04-bulk-operations-and-provisioning-04-05-PLAN.md
last_updated: "2026-04-03T08:03:50.656Z"
last_activity: 2026-04-02 — Plan 03-08 complete — SearchViewModel + DuplicatesViewModel + Views visual checkpoint approved
progress:
total_phases: 5
completed_phases: 3
total_plans: 33
completed_plans: 24
completed_plans: 25
percent: 65
---
@@ -84,6 +84,7 @@ Progress: [██████░░░░] 65%
| Phase 03-storage P05 | 4min | 2 tasks | 3 files |
| Phase 03 P08 | 4min | 3 tasks | 9 files |
| Phase 04-bulk-operations-and-provisioning P01 | 7min | 2 tasks | 27 files |
| Phase 04-bulk-operations-and-provisioning P05 | 6min | 2 tasks | 3 files |
## Accumulated Context
@@ -154,6 +155,7 @@ Recent decisions affecting current work:
- [Phase 03]: DuplicateRow flat DTO wraps DuplicateItem with GroupName and GroupSize for DataGrid display
- [Phase 04-bulk-operations-and-provisioning]: ITemplateService uses ModelSiteTemplate alias — SiteTemplate is ambiguous between SharepointToolbox.Core.Models and Microsoft.SharePoint.Client; resolved with using alias
- [Phase 04-bulk-operations-and-provisioning]: ICsvValidationService and BulkResultCsvExportService require explicit System.IO using — WPF project does not include System.IO in implicit usings (established project pattern)
- [Phase 04-bulk-operations-and-provisioning]: BulkSiteService uses Core.Helpers.ExecuteQueryRetryHelper not Infrastructure.Auth — plan had wrong using; correct namespace is Core.Helpers (established pattern from Phase 2/3 services)
### Pending Todos
@@ -166,6 +168,6 @@ None yet.
## Session Continuity
Last session: 2026-04-03T07:55:04.916Z
Stopped at: Completed 04-bulk-operations-and-provisioning-04-01-PLAN.md
Last session: 2026-04-03T08:03:50.653Z
Stopped at: Completed 04-bulk-operations-and-provisioning-04-05-PLAN.md
Resume file: None

View File

@@ -0,0 +1,131 @@
---
phase: 04-bulk-operations-and-provisioning
plan: 05
subsystem: bulk-site-creation
tags: [pnp-framework, bulk-operations, sharepoint-site-creation, team-site, communication-site]
# Dependency graph
requires:
- phase: 04-01
provides: "IBulkSiteService, BulkSiteRow, BulkOperationRunner"
- phase: 01-foundation
provides: "ExecuteQueryRetryHelper for throttle-safe CSOM calls"
provides:
- "BulkSiteService implementing IBulkSiteService via PnP Framework CreateSiteAsync"
- "Team site creation with alias + owners array via TeamSiteCollectionCreationInformation"
- "Communication site creation with auto-generated URL via CommunicationSiteCollectionCreationInformation"
- "Member/owner assignment post-creation via CSOM AssociatedMemberGroup/AssociatedOwnerGroup"
- "SanitizeAlias helper: removes special chars, replaces spaces with dashes, lowercases"
affects: [04-09, 04-10]
# Tech tracking
tech-stack:
added: []
patterns:
- "BulkSiteService.CreateSingleSiteAsync — type dispatch (Team vs Communication) before PnP call"
- "Communication site URL construction: https://{tenantHost}/sites/{alias}"
- "Post-creation member add: EnsureUser + AssociatedMemberGroup.Users.AddUser per email"
- "ParseEmails: Split(',', RemoveEmptyEntries | TrimEntries) from comma-separated CSV field"
- "SanitizeAlias: keep letters/digits/spaces/dashes, replace space with dash, lowercase"
key-files:
created:
- "SharepointToolbox/Services/BulkSiteService.cs"
- "SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs"
modified:
- "SharepointToolbox/Services/BulkMemberService.cs"
key-decisions:
- "BulkSiteService uses SharepointToolbox.Core.Helpers.ExecuteQueryRetryHelper (not Infrastructure.Auth) — plan had wrong using; correct namespace is Core.Helpers (established pattern from Phase 2/3 services)"
- "Communication site URL built from adminCtx.Url host — ensures correct tenant hostname without hardcoding"
# Metrics
duration: 6min
completed: 2026-04-03
---
# Phase 04 Plan 05: BulkSiteService Implementation Summary
**BulkSiteService implements IBulkSiteService using PnP Framework CreateSiteAsync for Team and Communication site bulk creation with per-site error handling**
## Performance
- **Duration:** 6 min
- **Started:** 2026-04-03T07:57:03Z
- **Completed:** 2026-04-03T08:02:15Z
- **Tasks:** 2 (both committed together)
- **Files modified:** 3
## Accomplishments
- Implemented `BulkSiteService` with full `IBulkSiteService` contract
- Team site creation via `TeamSiteCollectionCreationInformation` with `Alias`, `DisplayName`, `IsPublic=false`, and `Owners[]`
- Communication site creation via `CommunicationSiteCollectionCreationInformation` with auto-generated URL from `adminCtx.Url` host
- Post-creation member/owner assignment via `EnsureUser` + `AssociatedMemberGroup/OwnerGroup.Users.AddUser`
- Per-site error handling delegates to `BulkOperationRunner.RunAsync` with continue-on-error semantics
- `ParseEmails` helper splits comma-separated owner/member CSV fields
- `SanitizeAlias` generates URL-safe aliases from display names
- 3 passing tests (interface check, default values, CSV field inspection) + 3 skipped (live SP required)
## Task Commits
1. **Task 1+2: Implement BulkSiteService + BulkSiteServiceTests** - `b0956ad` (feat)
## Files Created/Modified
- `SharepointToolbox/Services/BulkSiteService.cs` — Full IBulkSiteService implementation with PnP Framework
- `SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs` — 3 passing + 3 skipped unit tests
- `SharepointToolbox/Services/BulkMemberService.cs` — Fixed pre-existing Group type ambiguity (Rule 1)
## Decisions Made
- `BulkSiteService` imports `SharepointToolbox.Core.Helpers` not `SharepointToolbox.Infrastructure.Auth` — plan listed wrong using directive; `ExecuteQueryRetryHelper` lives in `Core.Helpers` as established by all Phase 2/3 services
- Communication site URL is constructed from `adminCtx.Url` hostname to ensure tenant-correct URLs without hardcoding
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] Fixed wrong using directive in BulkSiteService**
- **Found during:** Task 1 (implementation)
- **Issue:** Plan code had `using SharepointToolbox.Infrastructure.Auth;``ExecuteQueryRetryHelper` is in `SharepointToolbox.Core.Helpers`
- **Fix:** Replaced `using SharepointToolbox.Infrastructure.Auth;` with `using SharepointToolbox.Core.Helpers;`
- **Files modified:** `SharepointToolbox/Services/BulkSiteService.cs`
- **Commit:** `b0956ad`
**2. [Rule 1 - Bug] Fixed BulkMemberService.cs Group type ambiguity**
- **Found during:** Task 1 (build verification)
- **Issue:** `Group? targetGroup = null;` on line 164 was ambiguous between `Microsoft.SharePoint.Client.Group` and `Microsoft.Graph.Models.Group` — CS0104 compile error
- **Fix:** Linter auto-applied `using SpGroup = Microsoft.SharePoint.Client.Group;` alias + used `SpGroup?`
- **Files modified:** `SharepointToolbox/Services/BulkMemberService.cs`
- **Commit:** `b0956ad`
---
**Total deviations:** 2 auto-fixed (2 x Rule 1 - compile bugs)
**Impact:** Both fixes were required for compilation. No scope creep. BulkMemberService fix is out-of-scope (from a previous plan) but was blocking the build entirely.
## Issues Encountered
The WPF temp project build lock (`MainWindow.g.cs` locked by another process) prevented `dotnet build SharepointToolbox.slnx` from completing. The test project build (`dotnet build SharepointToolbox.Tests`) succeeds normally and was used for verification.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- `BulkSiteService` is ready for use by BulkSiteViewModel (Plan 04-09) and BulkSiteView (Plan 04-10)
- All 3 non-skip tests pass; live integration tests remain skipped pending live SP admin context
- Build: `dotnet build SharepointToolbox.Tests` succeeds with 0 errors, 0 warnings
## Self-Check: PASSED
- BulkSiteService.cs: FOUND at `SharepointToolbox/Services/BulkSiteService.cs`
- BulkSiteServiceTests.cs: FOUND at `SharepointToolbox.Tests/Services/BulkSiteServiceTests.cs`
- 04-05-SUMMARY.md: FOUND (this file)
- Commit b0956ad: FOUND
---
*Phase: 04-bulk-operations-and-provisioning*
*Completed: 2026-04-03*

View File

@@ -0,0 +1,372 @@
using Microsoft.SharePoint.Client;
using PnP.Framework.Sites;
using Serilog;
using SharepointToolbox.Core.Helpers;
using SharepointToolbox.Core.Models;
using ModelSiteTemplate = SharepointToolbox.Core.Models.SiteTemplate;
namespace SharepointToolbox.Services;
public class TemplateService : ITemplateService
{
private static readonly HashSet<string> SystemListNames = new(StringComparer.OrdinalIgnoreCase)
{
"Style Library", "Form Templates", "Site Assets", "Site Pages",
"Composed Looks", "Master Page Gallery", "Web Part Gallery",
"Theme Gallery", "Solution Gallery", "List Template Gallery",
"Converted Forms", "Customized Reports", "Content type publishing error log",
"TaxonomyHiddenList", "appdata", "appfiles"
};
public async Task<ModelSiteTemplate> CaptureTemplateAsync(
ClientContext ctx,
SiteTemplateOptions options,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
progress.Report(new OperationProgress(0, 0, "Loading site properties..."));
var web = ctx.Web;
ctx.Load(web, w => w.Title, w => w.Description, w => w.Language,
w => w.SiteLogoUrl, w => w.WebTemplate, w => w.Configuration,
w => w.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var siteType = (web.WebTemplate == "GROUP" || web.WebTemplate == "GROUP#0")
? "Team" : "Communication";
var template = new ModelSiteTemplate
{
Name = string.Empty, // caller sets this
SourceUrl = ctx.Url,
CapturedAt = DateTime.UtcNow,
SiteType = siteType,
Options = options,
};
// Capture settings
if (options.CaptureSettings)
{
template.Settings = new TemplateSettings
{
Title = web.Title,
Description = web.Description,
Language = (int)web.Language,
};
}
// Capture logo
if (options.CaptureLogo)
{
template.Logo = new TemplateLogo { LogoUrl = web.SiteLogoUrl ?? string.Empty };
}
// Capture libraries and folders
if (options.CaptureLibraries || options.CaptureFolders)
{
progress.Report(new OperationProgress(0, 0, "Enumerating libraries..."));
var lists = ctx.LoadQuery(web.Lists
.Include(l => l.Title, l => l.Hidden, l => l.BaseType, l => l.BaseTemplate, l => l.RootFolder)
.Where(l => !l.Hidden));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var filteredLists = lists
.Where(l => !SystemListNames.Contains(l.Title))
.Where(l => l.BaseType == BaseType.DocumentLibrary || l.BaseType == BaseType.GenericList)
.ToList();
for (int i = 0; i < filteredLists.Count; i++)
{
ct.ThrowIfCancellationRequested();
var list = filteredLists[i];
progress.Report(new OperationProgress(i + 1, filteredLists.Count,
$"Capturing library: {list.Title}"));
var libInfo = new TemplateLibraryInfo
{
Name = list.Title,
BaseType = list.BaseType.ToString(),
BaseTemplate = (int)list.BaseTemplate,
};
if (options.CaptureFolders)
{
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
libInfo.Folders = await EnumerateFoldersRecursiveAsync(
ctx, list.RootFolder, string.Empty, progress, ct);
}
template.Libraries.Add(libInfo);
}
}
// Capture permission groups
if (options.CapturePermissionGroups)
{
progress.Report(new OperationProgress(0, 0, "Capturing permission groups..."));
var groups = web.SiteGroups;
ctx.Load(groups, gs => gs.Include(
g => g.Title, g => g.Description));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var group in groups)
{
ct.ThrowIfCancellationRequested();
// Load role definitions for this group
var roleAssignments = web.RoleAssignments;
ctx.Load(roleAssignments, ras => ras.Include(
ra => ra.Member.LoginName,
ra => ra.Member.Title,
ra => ra.RoleDefinitionBindings.Include(rd => rd.Name)));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var roles = new List<string>();
foreach (var ra in roleAssignments)
{
if (ra.Member.Title == group.Title)
{
foreach (var rd in ra.RoleDefinitionBindings)
{
roles.Add(rd.Name);
}
}
}
template.PermissionGroups.Add(new TemplatePermissionGroup
{
Name = group.Title,
Description = group.Description ?? string.Empty,
RoleDefinitions = roles,
});
}
}
progress.Report(new OperationProgress(1, 1, "Template capture complete."));
return template;
}
public async Task<string> ApplyTemplateAsync(
ClientContext adminCtx,
ModelSiteTemplate template,
string newSiteTitle,
string newSiteAlias,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
// 1. Create the site
progress.Report(new OperationProgress(0, 0, $"Creating {template.SiteType} site: {newSiteTitle}..."));
string siteUrl;
if (template.SiteType.Equals("Team", StringComparison.OrdinalIgnoreCase))
{
var info = new TeamSiteCollectionCreationInformation
{
DisplayName = newSiteTitle,
Alias = newSiteAlias,
Description = template.Settings?.Description ?? string.Empty,
IsPublic = false,
};
using var siteCtx = await adminCtx.CreateSiteAsync(info);
siteCtx.Load(siteCtx.Web, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
siteUrl = siteCtx.Web.Url;
}
else
{
var tenantHost = new Uri(adminCtx.Url).Host;
var info = new CommunicationSiteCollectionCreationInformation
{
Title = newSiteTitle,
Url = $"https://{tenantHost}/sites/{newSiteAlias}",
Description = template.Settings?.Description ?? string.Empty,
};
using var siteCtx = await adminCtx.CreateSiteAsync(info);
siteCtx.Load(siteCtx.Web, w => w.Url);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(siteCtx, progress, ct);
siteUrl = siteCtx.Web.Url;
}
// 2. Connect to the new site and apply template structure
// Need a new context for the created site
var newCtx = new ClientContext(siteUrl);
// Copy auth cookies/token from admin context
newCtx.Credentials = adminCtx.Credentials;
try
{
// Apply libraries
if (template.Libraries.Count > 0)
{
for (int i = 0; i < template.Libraries.Count; i++)
{
ct.ThrowIfCancellationRequested();
var lib = template.Libraries[i];
progress.Report(new OperationProgress(i + 1, template.Libraries.Count,
$"Creating library: {lib.Name}"));
try
{
var listInfo = new ListCreationInformation
{
Title = lib.Name,
TemplateType = lib.BaseTemplate,
};
var newList = newCtx.Web.Lists.Add(listInfo);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
// Create folders in the library
if (lib.Folders.Count > 0)
{
await CreateFoldersFromTemplateAsync(newCtx, newList, lib.Folders, progress, ct);
}
}
catch (Exception ex)
{
Log.Warning("Failed to create library {Name}: {Error}", lib.Name, ex.Message);
}
}
}
// Apply permission groups
if (template.PermissionGroups.Count > 0)
{
progress.Report(new OperationProgress(0, 0, "Creating permission groups..."));
foreach (var group in template.PermissionGroups)
{
ct.ThrowIfCancellationRequested();
try
{
var groupInfo = new GroupCreationInformation
{
Title = group.Name,
Description = group.Description,
};
var newGroup = newCtx.Web.SiteGroups.Add(groupInfo);
// Assign role definitions
foreach (var roleName in group.RoleDefinitions)
{
try
{
var roleDef = newCtx.Web.RoleDefinitions.GetByName(roleName);
var roleBindings = new RoleDefinitionBindingCollection(newCtx) { roleDef };
newCtx.Web.RoleAssignments.Add(newGroup, roleBindings);
}
catch (Exception ex)
{
Log.Warning("Failed to assign role {Role} to group {Group}: {Error}",
roleName, group.Name, ex.Message);
}
}
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
}
catch (Exception ex)
{
Log.Warning("Failed to create group {Name}: {Error}", group.Name, ex.Message);
}
}
}
// Apply logo
if (template.Logo != null && !string.IsNullOrEmpty(template.Logo.LogoUrl))
{
try
{
newCtx.Web.SiteLogoUrl = template.Logo.LogoUrl;
newCtx.Web.Update();
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(newCtx, progress, ct);
}
catch (Exception ex)
{
Log.Warning("Failed to set site logo: {Error}", ex.Message);
}
}
}
finally
{
newCtx.Dispose();
}
progress.Report(new OperationProgress(1, 1, $"Template applied. Site created at: {siteUrl}"));
return siteUrl;
}
private async Task<List<TemplateFolderInfo>> EnumerateFoldersRecursiveAsync(
ClientContext ctx,
Folder parentFolder,
string parentRelativePath,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var result = new List<TemplateFolderInfo>();
ctx.Load(parentFolder, f => f.Folders.Include(sf => sf.Name, sf => sf.ServerRelativeUrl));
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
foreach (var subFolder in parentFolder.Folders)
{
// Skip system folders
if (subFolder.Name.StartsWith("_") || subFolder.Name == "Forms")
continue;
var relativePath = string.IsNullOrEmpty(parentRelativePath)
? subFolder.Name
: $"{parentRelativePath}/{subFolder.Name}";
var folderInfo = new TemplateFolderInfo
{
Name = subFolder.Name,
RelativePath = relativePath,
Children = await EnumerateFoldersRecursiveAsync(ctx, subFolder, relativePath, progress, ct),
};
result.Add(folderInfo);
}
return result;
}
private static async Task CreateFoldersFromTemplateAsync(
ClientContext ctx,
List list,
List<TemplateFolderInfo> folders,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
ctx.Load(list.RootFolder, f => f.ServerRelativeUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
var baseUrl = list.RootFolder.ServerRelativeUrl.TrimEnd('/');
await CreateFoldersRecursiveAsync(ctx, baseUrl, folders, progress, ct);
}
private static async Task CreateFoldersRecursiveAsync(
ClientContext ctx,
string parentUrl,
List<TemplateFolderInfo> folders,
IProgress<OperationProgress> progress,
CancellationToken ct)
{
foreach (var folder in folders)
{
ct.ThrowIfCancellationRequested();
try
{
var folderUrl = $"{parentUrl}/{folder.Name}";
ctx.Web.Folders.Add(folderUrl);
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
if (folder.Children.Count > 0)
{
await CreateFoldersRecursiveAsync(ctx, folderUrl, folder.Children, progress, ct);
}
}
catch (Exception ex)
{
Log.Warning("Failed to create folder {Path}: {Error}", folder.RelativePath, ex.Message);
}
}
}
}