Архивация списков (#13)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
This commit was merged in pull request #13.
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>Созданный список: серверный относительный url (он же ListId) и Title.</summary>
|
||||
public sealed record ProvisionedList(string ListId, string Title);
|
||||
|
||||
/// <summary>
|
||||
/// Создание следующей пары списков серии в SharePoint («<Форма> <N> — данные»
|
||||
/// + «<Форма> <N> — согласование») дублированием текущей пары.
|
||||
///
|
||||
/// Интерфейс живёт в Infrastructure, а не в Application, потому что Application уже
|
||||
/// ссылается на Infrastructure (так же устроен <see cref="ISharePointClient"/>) —
|
||||
/// обратная ссылка замкнула бы проекты в кольцо.
|
||||
/// </summary>
|
||||
public interface ISharePointListProvisioner
|
||||
{
|
||||
/// <summary>
|
||||
/// Создать пару списков с номером <paramref name="number"/>, скопировав структуру
|
||||
/// со списка <paramref name="sourceDataListId"/>. Возвращает созданный список ДАННЫХ
|
||||
/// (список согласования создаётся тем же вызовом, но наружу не отдаётся — его
|
||||
/// находят по конвенции имени).
|
||||
/// </summary>
|
||||
Task<ProvisionedList> CreateNextListPairAsync(
|
||||
string siteUrl,
|
||||
string sourceDataListId,
|
||||
int number,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>
|
||||
/// Конвенция имён списков заявок: «<Форма> <N> — данные» и парный ему
|
||||
/// «<Форма> <N> — согласование».
|
||||
///
|
||||
/// Третья копия одного и того же соглашения — оно же в провижинере request-portal
|
||||
/// (provisioning/Provision-Lists.ps1, Get-NumberedListTitle) и в самом портале
|
||||
/// (shared/listTitles.ts). Держать в одном месте не получается: три разных runtime.
|
||||
/// Меняя здесь, менять и там.
|
||||
///
|
||||
/// Номер стоит ПЕРЕД суффиксом, потому что имя списка согласования и портал, и
|
||||
/// админка выводят из имени списка данных регексом, привязанным к концу строки.
|
||||
/// </summary>
|
||||
internal static class ListTitleConvention
|
||||
{
|
||||
private const string DataSuffix = "данные";
|
||||
private const string ApprovalSuffix = "согласование";
|
||||
|
||||
private static readonly Regex TitlePattern = new(
|
||||
@"^(?<base>.+?)\s*—\s*(?<suffix>данные|согласование)\s*$",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex TrailingNumber = new(@"\s+\d+$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Имя того же списка с номером <paramref name="number"/>. Уже имеющийся номер
|
||||
/// заменяется, а не дописывается вторым.
|
||||
/// </summary>
|
||||
public static string WithNumber(string title, int number)
|
||||
{
|
||||
var match = TitlePattern.Match(title);
|
||||
if (!match.Success)
|
||||
throw new InvalidOperationException(
|
||||
$"Имя списка «{title}» не по конвенции «<Форма> — данные|согласование» — номер поставить некуда.");
|
||||
|
||||
var baseName = TrailingNumber.Replace(match.Groups["base"].Value, string.Empty);
|
||||
return $"{baseName} {number} — {match.Groups["suffix"].Value}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Имя парного списка согласования по имени списка данных (номер сохраняется).
|
||||
/// null — имя не по конвенции либо это не список данных.
|
||||
/// </summary>
|
||||
public static string? ToApprovalTitle(string dataListTitle)
|
||||
{
|
||||
var match = TitlePattern.Match(dataListTitle);
|
||||
if (!match.Success || match.Groups["suffix"].Value != DataSuffix) return null;
|
||||
|
||||
return $"{match.Groups["base"].Value} — {ApprovalSuffix}";
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,45 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список.</summary>
|
||||
public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount);
|
||||
|
||||
/// <summary>Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке.</summary>
|
||||
public sealed record SharePointFieldSchema(string InternalName, string SchemaXml);
|
||||
|
||||
public interface ISharePointClient
|
||||
{
|
||||
/// <summary>Список по серверному относительному url. null — списка нет.</summary>
|
||||
Task<SharePointListInfo?> GetListByUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Список по заголовку. null — списка нет.</summary>
|
||||
Task<SharePointListInfo?> GetListByTitleAsync(
|
||||
string siteUrl, string listTitle, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные
|
||||
/// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы:
|
||||
/// они появляются в новом списке сами.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Создать пустой список (GenericList с версионированием) и вернуть его.</summary>
|
||||
Task<SharePointListInfo> CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Создать колонку в списке из её SchemaXml.</summary>
|
||||
Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
|
||||
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) —
|
||||
/// по нему решается, не пора ли заводить следующий список серии.
|
||||
/// </summary>
|
||||
Task<int?> GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
|
||||
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
|
||||
@@ -50,6 +84,116 @@ public sealed class SharePointClient(
|
||||
return json.TryGetProperty("Title", out var title) ? title.GetString() : null;
|
||||
}
|
||||
|
||||
public Task<SharePointListInfo?> GetListByUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) =>
|
||||
GetListInfoAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')",
|
||||
cancellationToken);
|
||||
|
||||
public Task<SharePointListInfo?> GetListByTitleAsync(
|
||||
string siteUrl, string listTitle, CancellationToken cancellationToken) =>
|
||||
GetListInfoAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')",
|
||||
cancellationToken);
|
||||
|
||||
private async Task<SharePointListInfo?> GetListInfoAsync(
|
||||
string siteUrl, string listUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder";
|
||||
var json = await TryGetAsync(siteUrl, url, cancellationToken);
|
||||
if (json is null) return null;
|
||||
|
||||
var value = json.Value;
|
||||
var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder)
|
||||
&& rootFolder.TryGetProperty("ServerRelativeUrl", out var relative)
|
||||
? relative.GetString()
|
||||
: null;
|
||||
|
||||
if (serverRelativeUrl is null) return null;
|
||||
|
||||
return new SharePointListInfo(
|
||||
value.GetProperty("Id").GetGuid(),
|
||||
value.GetProperty("Title").GetString() ?? string.Empty,
|
||||
serverRelativeUrl,
|
||||
value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
// Ни FromBaseType, ни CanBeDeleted не годятся как признак «колонку явно
|
||||
// добавили в список»: у переиспользуемых стандартных site-колонок SharePoint
|
||||
// (например, «Department» — встроенная текстовая колонка из схемы v3,
|
||||
// подключаемая через DelayActivateTemplateBinding) ОБА флага стоят как у
|
||||
// настоящего системного поля, хотя колонка реально добавлена в список и в
|
||||
// неё пишут данные. Из-за этого такие колонки тихо выпадали из копирования —
|
||||
// без ошибки, просто без предупреждения.
|
||||
//
|
||||
// Поэтому здесь фильтруем только Hidden/ReadOnlyField (их создать всё равно
|
||||
// нельзя), а отделять настоящие базовые поля (Title, ID, ContentType …) от
|
||||
// явно добавленных — задача вызывающего кода: он сравнивает с полями
|
||||
// ТОЛЬКО ЧТО СОЗДАННОГО пустого списка того же шаблона (см. CloneAsync,
|
||||
// alreadyThere) — то, что есть на обоих, действительно базовое; то, что
|
||||
// есть только на источнике, добавлено явно, независимо от флагов SharePoint.
|
||||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" +
|
||||
"?$select=InternalName,SchemaXml,Hidden,ReadOnlyField" +
|
||||
"&$filter=Hidden eq false and ReadOnlyField eq false";
|
||||
|
||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||
var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array
|
||||
? value.EnumerateArray()
|
||||
: json.EnumerateArray();
|
||||
|
||||
var fields = new List<SharePointFieldSchema>();
|
||||
foreach (var row in rows)
|
||||
{
|
||||
var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null;
|
||||
var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null;
|
||||
if (internalName is null || schemaXml is null) continue;
|
||||
|
||||
fields.Add(new SharePointFieldSchema(internalName, schemaXml));
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
public async Task<SharePointListInfo> CreateListAsync(
|
||||
string siteUrl, string title, CancellationToken cancellationToken)
|
||||
{
|
||||
// BaseTemplate 100 — GenericList, как -Template GenericList у провижинера.
|
||||
await PostAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists",
|
||||
JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }),
|
||||
cancellationToken);
|
||||
|
||||
// Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url —
|
||||
// дочитываем список отдельным запросом.
|
||||
return await GetListByTitleAsync(siteUrl, title, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно.");
|
||||
}
|
||||
|
||||
public Task AddFieldFromXmlAsync(
|
||||
string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) =>
|
||||
PostAsync(
|
||||
siteUrl,
|
||||
$"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml",
|
||||
// Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить
|
||||
// InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается
|
||||
// код портала именно по внутреннему имени, так что это не косметика.
|
||||
JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }),
|
||||
cancellationToken);
|
||||
|
||||
public async Task<int?> GetItemCountAsync(
|
||||
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount";
|
||||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||||
return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : null;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
|
||||
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
|
||||
GetItemsAsync(
|
||||
@@ -79,9 +223,26 @@ public sealed class SharePointClient(
|
||||
: [];
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GetAsync(string siteUrl, string url, CancellationToken cancellationToken)
|
||||
private async Task<JsonElement> GetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
|
||||
await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken)
|
||||
?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}.");
|
||||
|
||||
/// <summary>GET, для которого «нет такого списка» — не ошибка, а null.</summary>
|
||||
private Task<JsonElement?> TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
|
||||
SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken);
|
||||
|
||||
private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) =>
|
||||
SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken);
|
||||
|
||||
private async Task<JsonElement?> SendAsync(
|
||||
HttpMethod method,
|
||||
string siteUrl,
|
||||
string url,
|
||||
string? body,
|
||||
bool allowNotFound,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
using var request = new HttpRequestMessage(method, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue(
|
||||
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
|
||||
@@ -89,14 +250,30 @@ public sealed class SharePointClient(
|
||||
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
|
||||
});
|
||||
|
||||
if (body is not null)
|
||||
{
|
||||
request.Content = new StringContent(body);
|
||||
// odata=nometadata и на запись: тогда в теле не нужен блок __metadata с
|
||||
// типом сущности — SharePoint выводит тип из самого эндпоинта.
|
||||
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json")
|
||||
{
|
||||
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
|
||||
};
|
||||
}
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException(
|
||||
$"SharePoint ответил {(int)response.StatusCode} на {url}: {body}");
|
||||
$"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}");
|
||||
|
||||
using var document = JsonDocument.Parse(body);
|
||||
if (string.IsNullOrWhiteSpace(responseBody)) return null;
|
||||
|
||||
using var document = JsonDocument.Parse(responseBody);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Tnsu.ApprovalRoute.Infrastructure.TnsuPortal;
|
||||
|
||||
namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт следующую пару списков ДУБЛИРОВАНИЕМ текущей: берёт структуру живого
|
||||
/// списка (его пользовательские колонки) и повторяет её в новом списке с
|
||||
/// номером N+1. Манифесты provisioning.json request-portal при этом не читаются —
|
||||
/// источник структуры сам SharePoint, поэтому дубль не может разойтись с тем, что
|
||||
/// реально в списке.
|
||||
///
|
||||
/// Идемпотентен: список с целевым именем переиспользуется, уже существующие колонки
|
||||
/// пропускаются. Повторный запуск после частичного сбоя дозаполняет недостающее, а
|
||||
/// не создаёт второй список.
|
||||
/// </summary>
|
||||
public sealed class SharePointListProvisioner(
|
||||
ISharePointClient sharePoint,
|
||||
ISharePointWebhookSubscriber webhookSubscriber,
|
||||
ILogger<SharePointListProvisioner> logger) : ISharePointListProvisioner
|
||||
{
|
||||
public async Task<ProvisionedList> CreateNextListPairAsync(
|
||||
string siteUrl,
|
||||
string sourceDataListId,
|
||||
int number,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Дублирую пару списков №{Number} на {SiteUrl} по образцу {SourceListId}.",
|
||||
number, siteUrl, sourceDataListId);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var sourceData = await sharePoint.GetListByUrlAsync(siteUrl, sourceDataListId, cancellationToken)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Исходный список {sourceDataListId} не найден на {siteUrl} — дублировать нечего.");
|
||||
|
||||
var newData = await CloneAsync(
|
||||
siteUrl,
|
||||
sourceData,
|
||||
ListTitleConvention.WithNumber(sourceData.Title, number),
|
||||
role: "данные",
|
||||
retarget: null,
|
||||
cancellationToken);
|
||||
|
||||
await CloneApprovalPairAsync(siteUrl, sourceData, newData, number, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
"Пара списков №{Number} готова за {ElapsedMs} мс: данные «{Title}» ({ListId}).",
|
||||
number, stopwatch.ElapsedMilliseconds, newData.Title, newData.ServerRelativeUrl);
|
||||
|
||||
// Подписка — ПОСЛЕДНИМ шагом, когда списки уже созданы:
|
||||
// • подписывать нечего, пока списка нет;
|
||||
// • только для списка ДАННЫХ — заявки портал отслеживает по нему, списку
|
||||
// согласования подписка не нужна;
|
||||
// • в самом конце, а не сразу после создания списка данных: сорвись
|
||||
// дублирование пары на списке согласования, подписка была бы уже
|
||||
// заведена, и повтор задачи Hangfire отправил бы второй запрос на ту же
|
||||
// подписку (идемпотентна ли ручка портала — неизвестно).
|
||||
// Не бросает и провижининг не рушит (см. ISharePointWebhookSubscriber).
|
||||
await webhookSubscriber.TrySubscribeAsync(siteUrl, newData.Title, cancellationToken);
|
||||
|
||||
return new ProvisionedList(newData.ServerRelativeUrl, newData.Title);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Дублирует парный список согласования. Его не на что сослаться, кроме имени:
|
||||
/// в БД хранится ссылка только на список данных, а согласование ищется по
|
||||
/// конвенции — так же, как это делают портал и админка.
|
||||
/// </summary>
|
||||
private async Task CloneApprovalPairAsync(
|
||||
string siteUrl,
|
||||
SharePointListInfo sourceData,
|
||||
SharePointListInfo newData,
|
||||
int number,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceApprovalTitle = ListTitleConvention.ToApprovalTitle(sourceData.Title);
|
||||
if (sourceApprovalTitle is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Список «{Title}» не по конвенции «… — данные» — парный список согласования не дублируется.",
|
||||
sourceData.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceApproval = await sharePoint.GetListByTitleAsync(siteUrl, sourceApprovalTitle, cancellationToken);
|
||||
if (sourceApproval is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Список согласования «{Title}» не найден — дублируется только список данных.",
|
||||
sourceApprovalTitle);
|
||||
return;
|
||||
}
|
||||
|
||||
await CloneAsync(
|
||||
siteUrl,
|
||||
sourceApproval,
|
||||
ListTitleConvention.WithNumber(sourceApproval.Title, number),
|
||||
role: "согласование",
|
||||
// Ticket списка согласования смотрит на список данных — в дубле он должен
|
||||
// смотреть на НОВЫЙ список данных, иначе строки маршрута новых заявок
|
||||
// будут ссылаться в старый список.
|
||||
retarget: (sourceData.Id, newData.Id),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// role — только для логов («данные»/«согласование»): по нему в логе видно, какую
|
||||
/// половину пары обрабатывает конкретный вызов, не заглядывая в стек вызовов.
|
||||
/// </summary>
|
||||
private async Task<SharePointListInfo> CloneAsync(
|
||||
string siteUrl,
|
||||
SharePointListInfo source,
|
||||
string newTitle,
|
||||
string role,
|
||||
(Guid From, Guid To)? retarget,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await sharePoint.GetListByTitleAsync(siteUrl, newTitle, cancellationToken);
|
||||
var target = existing ?? await sharePoint.CreateListAsync(siteUrl, newTitle, cancellationToken);
|
||||
|
||||
logger.LogInformation(
|
||||
existing is null
|
||||
? "Список {Role} «{Title}» создан."
|
||||
: "Список {Role} «{Title}» уже существует — дозаполняю недостающие колонки.",
|
||||
role, newTitle);
|
||||
|
||||
var sourceFields = await sharePoint.GetCustomFieldsAsync(siteUrl, source.ServerRelativeUrl, cancellationToken);
|
||||
var targetFields = await sharePoint.GetCustomFieldsAsync(siteUrl, target.ServerRelativeUrl, cancellationToken);
|
||||
var alreadyThere = targetFields.Select(x => x.InternalName).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var copied = 0;
|
||||
foreach (var field in sourceFields)
|
||||
{
|
||||
if (alreadyThere.Contains(field.InternalName)) continue;
|
||||
|
||||
var schemaXml = retarget is null
|
||||
? field.SchemaXml
|
||||
: RetargetLookup(field.SchemaXml, retarget.Value.From, retarget.Value.To);
|
||||
|
||||
await sharePoint.AddFieldFromXmlAsync(siteUrl, target.Id, schemaXml, cancellationToken);
|
||||
copied++;
|
||||
|
||||
// По каждой колонке — только на Debug: при штатной первой партии их
|
||||
// десятки на список, и на Information это был бы шум. Итог всё равно
|
||||
// виден в сводке ниже.
|
||||
logger.LogDebug(
|
||||
"Список {Role} «{Title}»: скопирована колонка {InternalName}.", role, newTitle, field.InternalName);
|
||||
}
|
||||
|
||||
logger.LogInformation(
|
||||
"Список {Role} «{Title}»: скопировано колонок {Copied}, уже было {AlreadyThere}.",
|
||||
role, newTitle, copied, sourceFields.Count - copied);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Перенацеливает Lookup на новый список данных.
|
||||
///
|
||||
/// Целевой список Lookup хранит в SchemaXml GUID'ом (атрибут List), поэтому
|
||||
/// подменяем именно GUID. Подменяем ТОЛЬКО ссылки на исходный список данных:
|
||||
/// кросс-формные lookup'ы (например, «организация рабочего места» ссылается на
|
||||
/// список «приема на работу») должны и дальше смотреть на список ЧУЖОЙ формы.
|
||||
/// </summary>
|
||||
private static string RetargetLookup(string schemaXml, Guid from, Guid to)
|
||||
{
|
||||
var fromText = from.ToString("D");
|
||||
return schemaXml.Contains(fromText, StringComparison.OrdinalIgnoreCase)
|
||||
? schemaXml.Replace(fromText, to.ToString("D"), StringComparison.OrdinalIgnoreCase)
|
||||
: schemaXml;
|
||||
}
|
||||
}
|
||||
@@ -20,4 +20,15 @@ public sealed class SharePointOptions
|
||||
/// это здесь не стоит — пароль от сертификата в исходниках не место.
|
||||
/// </summary>
|
||||
public string CertificatePassword { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Куда SharePoint будет присылать уведомления об изменениях списка — параметр
|
||||
/// notificationUrl ручки POST /Webhook/Subscribe, которой новый список данных
|
||||
/// подписывается на уведомления (см. HttpSharePointWebhookSubscriber).
|
||||
/// Это приёмник на СТОРОНЕ ПОРТАЛА, а не адрес нашего сервиса.
|
||||
///
|
||||
/// Пусто — подписывать не будем вовсе: без адреса приёмника подписка
|
||||
/// бессмысленна, и это же единственный способ её выключить.
|
||||
/// </summary>
|
||||
public string NotificationUrl { get; set; } = "https://api.tnsu.kz/Webhook/SharePoint";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user