Перейти к содержимому

Кейсы работы с сущностями Portal

Практические сценарии написания кода: списки, библиотеки, пользователи, связи между модулями. Примеры основаны на демо-модулях из репозитория — их можно собрать и установить как .portalmod.

Пошаговые гайды: работа с узлами, работа со списками, сценарии Event Receiver.

См. также: Значения полей списков, Функциональные модули, SDK provisioner.

КонтекстSDKКлючи полей при записиПрава
Provisioner (установка .portalmod)Portal.Module.Sdkinternal_name (title, status…)Системные (установка модуля)
WebPart (страница / форма)Portal.WebPart.Sdkctx.Host + PortalListItemТекущий пользователь страницы
Event Receiver (событие списка)Portal.EventReceiver.Sdkcontext.GetListItemAsync(api) + PortalListItemПользователь события
Timer Job (расписание)Portal.TimerJob.SdkPortalListItem + id из Configсистемная учётка portal-system
REST API (внешняя система)HTTPUUID или internal_nameТокен / сессия с нужными правами

Паттерн расширений (рекомендуемый): текущий контекст (ctx.Host / context.GetListItemAsync) → item["internal_name"] / GetString… → CreateAsync / UpdateAsync (PortalListItem).

Низкоуровневый / REST: GetFieldsAsync → UUID → field_values[fieldId] — когда нужен сырой JSON или история версий.

Паттерн Provisioner: писать сразу по internal_name — платформа сама сопоставит поля схемы списка.


Задача: при установке модуля создать подразделения с родительскими связями, затем сотрудников со ссылками на подразделение и должность.

Где: C# provisioner модуля «Адресная книга».

Источник: AddressBookProvisioner.cs

// 1. Создать корневое подразделение, получить id последнего элемента
var hqId = await CreateDepartment(ctx, departmentsListId, "Головной офис", null, null, ct);
var itId = await CreateDepartment(ctx, departmentsListId, "ИТ", hqId, "Головной офис", ct);
// 2. Lookup-поле parent — объект { itemId, display }
private static async Task<Guid> CreateDepartment(..., Guid? parentId, string? parentTitle, ...)
{
var fields = new Dictionary<string, object?> { ["title"] = title };
if (parentId is not null)
{
fields["parent"] = new Dictionary<string, object?>
{
["itemId"] = parentId.Value.ToString(),
["display"] = parentTitle ?? title,
};
}
await ctx.Services.CreateListItemAsync(listId, fields, ct);
var ids = await ctx.Services.GetListItemIdsOrderedAsync(listId, ct);
return ids[^1]; // id только что созданного элемента
}
// 3. Сотрудник со ссылками на справочники
await ctx.Services.CreateListItemAsync(employeesListId, new Dictionary<string, object?>
{
["title"] = "Анна Иванова",
["full_name"] = "Анна Иванова",
["department"] = Lookup(itId, "ИТ"),
["position"] = Lookup(devId, "Разработчик"),
["birth_date"] = "1990-03-15",
}, ct);

Сущности: списки departments, positions, employees; поля lookup, date, text.

Идempotентность: перед созданием проверяйте GetListItemCountAsync > 0 — повторная установка не дублирует данные.


Кейс 2. Демо-данные с пользователями и полем person

Заголовок раздела «Кейс 2. Демо-данные с пользователями и полем person»

Задача: создать локальных пользователей и задачи с исполнителем, статусом и диапазоном дат.

Где: provisioner модуля «Задачи».

Источник: TasksProvisioner.cs

var listId = await ctx.Services.GetListIdAsync("lists.tasks", ct);
// Локальные пользователи (идемпотентно)
var userId = await ctx.Services.EnsureLocalUserAsync(
"demo.ivanova", "Анна Иванова", "ivanova@portal.local", ct);
// Поле person (один объект допустим; при allowMultiple: true хранится как массив)
await ctx.Services.CreateListItemAsync(listId, new Dictionary<string, object?>
{
["title"] = "Подготовить отчёт",
["description"] = "Сводка по итогам квартала",
["status"] = "В работе",
["priority"] = "Средний",
["start_at"] = DateOnly.FromDateTime(DateTime.Now).ToString("yyyy-MM-dd"),
["due_at"] = DateOnly.FromDateTime(DateTime.Now.AddDays(1)).ToString("yyyy-MM-dd"),
["assigned_to"] = new Dictionary<string, object?>
{
["principalType"] = "user",
["id"] = userId.ToString(),
["display"] = "Анна Иванова",
},
}, ct);

По умолчанию у новых person-полей allowMultiple: true — значение в API обычно массив субъектов. Один объект при создании по-прежнему принимается.

Сущности: список user-tasks; поля choice, date, person; пользователи Portal.


Кейс 3. Загрузка файлов в библиотеку из архива модуля

Заголовок раздела «Кейс 3. Загрузка файлов в библиотеку из архива модуля»

Задача: при установке положить PNG-баннеры в библиотеку документов узла.

Где: provisioner модуля «Баннеры».

Источник: BannersProvisioner.cs

var libraryId = await ctx.Services.GetLibraryIdAsync("libraries.bannerImages", ct);
// Файл из ZIP-архива .portalmod (каталог seed/ внутри пакета)
var bytes = ReadModuleFile(ctx, "seed/banners/banner-welcome.png");
if (bytes is { Length: > 0 })
{
await ctx.Services.UploadLibraryFileAsync(
libraryId, "banner-welcome.png", bytes, "image/png", ct);
}

Сущности: библиотека banner-images; файлы в seed/ внутри .portalmod.

WebPart «Баннеры» затем читает библиотеку по bannersLibraryId из Property Pane (задаётся в module.json через $ref).


Задача: отобразить оргструктуру — загрузить все подразделения, построить дерево, показать сотрудников выбранного отдела.

Где: WebPart «Адресная книга».

Источник: AddressBookWebPart.cs

// listId приходит из Property Pane (employeesListId / departmentsListId)
var config = await LoadConfigAsync(ctx, ct); // проверка Guid из ctx.Properties
var items = await ctx.Lists.GetListItemsAsync(config.EmployeesListId, limit: 500, ct);
foreach (var item in items)
{
var id = item.Guid;
var dept = item.GetLookup("department")?.Display;
var name = item.GetString("full_name");
// … построение HTML
}

Паттерн: GetListItemsAsync / QueryListItemsAsyncPortalListItem по internal_name (GetString, GetLookup, …).


Кейс 5. WebPart: создание элемента из формы (HandleActionAsync)

Заголовок раздела «Кейс 5. WebPart: создание элемента из формы (HandleActionAsync)»

Задача: пользователь кликает слот на таймлайне → заполняет форму → создаётся задача в списке.

Где: WebPart «Задачи».

Источник: TasksWebPart.cs — метод HandleCreateTaskAsync

public async Task<WebPartResult> HandleActionAsync(string action, JsonElement data, IWebPartContext ctx, CancellationToken ct)
{
if (action != "createTask") return ...;
var config = await LoadConfigAsync(ctx, ct); // Guid списка из Property Pane
var item = await ctx.Lists.NewListItemAsync(config.TasksListId, ct);
item["title"] = title;
item["status"] = "Новая";
item["start_at"] = start.ToString("yyyy-MM-dd");
item["assigned_to"] = new Dictionary<string, object?>
{
["principalType"] = "user",
["id"] = userId.ToString(),
["display"] = displayName,
};
await item.CreateAsync(ct);
return await RenderTimelineAsync(ctx, config, ...); // перерисовать UI
}

Сущности: список задач; действие из HTML через data-wp-action="createTask".


Кейс 6. Два связанных списка: бронирование с проверкой конфликта

Заголовок раздела «Кейс 6. Два связанных списка: бронирование с проверкой конфликта»

Задача: справочник «Комнаты» + список «Брони». WebPart показывает таймлайн, перед созданием проверяет пересечение интервалов.

Где: provisioner + WebPart «Переговорные».

Источник: MeetingRoomProvisioner.cs, MeetingRoomBookingWebPart.cs

Provisioner — lookup на комнату и datetime:

await ctx.Services.CreateListItemAsync(bookingsListId, new Dictionary<string, object?>
{
["title"] = "Стендап команды",
["room"] = Lookup(roomA, "Переговорная «Альфа»"),
["start_at"] = start.ToString("O"), // ISO 8601 для datetime
["end_at"] = end.ToString("O"),
["booked_by"] = Person(adminId, "Администратор"),
}, ct);

WebPart — проверка конфликта в памяти:

var bookings = await LoadBookingsAsync(ctx, config, date, ct);
if (HasConflict(bookings, roomId, start, end))
{
return await RenderTimelineAsync(..., formError: "Переговорная уже занята...", ct);
}
// Создание с lookup на комнату (PortalListItem / internal_name)
var item = await ctx.Lists.NewListItemAsync(config.BookingsListId, ct);
item["title"] = title;
item["room"] = new Dictionary<string, object?>
{
["itemId"] = roomId.ToString(),
["display"] = room.Title,
};
item["start_at"] = start.ToString("O");
item["end_at"] = end.ToString("O");
await item.CreateAsync(ct);
private static bool HasConflict(..., DateTime start, DateTime end) =>
bookings.Any(b => b.RoomItemId == roomId && b.Start < end && start < b.End);

Сущности: списки rooms, bookings; поля lookup, datetime, person.


Кейс 7. Зависимость модулей: изменение чужого списка

Заголовок раздела «Кейс 7. Зависимость модулей: изменение чужого списка»

Задача: модуль «Дни рождения» не создаёт список сотрудников — он использует employees из уже установленной «Адресной книги» и обновляет поле birth_date.

Где: provisioner с requires: ["demo.address-book@>=1.0.0"].

Источник: BirthdaysProvisioner.cs

// UUID списка из другого модуля (доступен после requires)
var employeesRef = ctx.Services.TryGetRef("modules.demo.address-book.lists.employees.id");
if (employeesRef is null || !Guid.TryParse(employeesRef, out var employeesListId))
{
ctx.Log.Warn("Список employees не найден — установите address-book первым");
return;
}
var itemIds = await ctx.Services.GetListItemIdsOrderedAsync(employeesListId, ct);
await ctx.Services.UpdateListItemAsync(employeesListId, itemIds[0], new Dictionary<string, object?>
{
["birth_date"] = $"{year}-{today.Month:D2}-{today.Day:D2}",
}, ct);

Сущности: cross-module $refmodules.{moduleId}.lists.{key}.id; порядок установки важен.


Кейс 8. Event Receiver: реакция на изменение элемента

Заголовок раздела «Кейс 8. Event Receiver: реакция на изменение элемента»

Задача: при обновлении элемента в целевом списке — залогировать и перечитать актуальное состояние (или запустить бизнес-логику).

Где: .portalevent, привязка к списку через config listId.

Источник: ListChangeHandler.cs

public async Task<object?> HandleAsync(EventContext context, IReceiverApi api, CancellationToken ct)
{
if (!context.IsListItemEvent()) return new { skipped = true };
var configuredListId = GetConfigGuid(context.ReceiverConfig, "listId");
var eventListId = context.GetListId();
// Обрабатываем только «свой» список
if (configuredListId != Guid.Empty
&& eventListId is Guid lid
&& configuredListId != lid)
return new { skipped = true };
if (context.EventName == "listItem.updated")
{
var item = await context.GetListItemAsync(api, ct);
if (item is null) return new { skipped = true };
var status = item.GetString("status");
// … item["status"] = …; await item.UpdateAsync(ct); постановка job и т.д.
}
return new { ok = true };
}

События: listItem.created, listItem.updated, listItem.deleted.

Event Receiver можно объявить в module.json модуля — при установке .portalmod привязка создаётся автоматически.


Кейс 9. Timer Job: периодическая обработка списка

Заголовок раздела «Кейс 9. Timer Job: периодическая обработка списка»

Задача: каждую ночь найти просроченные задачи и перевести статус в «Просрочена».

Где: .portaltimer, config экземпляра с tasksListId (права — portal-system).

public async Task<object?> ExecuteAsync(TimerJobContext context, ITimerJobApi api, CancellationToken ct)
{
// Права всегда от системной учётки portal-system (полный доступ)
var listId = Guid.Parse(context.Config.GetProperty("tasksListId").GetString()!);
var today = DateOnly.FromDateTime(DateTime.Today).ToString("yyyy-MM-dd");
// Фильтр на сервере — см. [Выборка элементов списка](/docs/developers/list-queries)
var items = await api.Lists.QueryListItemsAsync(listId,
ListItemQueryBuilder.ForList(listId)
.Where(b => b.And().Lte("due_at", today).Ne("status", "Просрочена").IsNotEmpty("due_at"))
.Take(500)
.Build(), ct);
var updated = 0;
foreach (var item in items)
{
item["status"] = "Просрочена";
await item.UpdateAsync(ct);
updated++;
}
return new { updated };
}

Сущности: Timer Job instance; операции со списками всегда от системной учётки portal-system.
Частые фильтры (статус, person, lookup, пагинация): list-queries.


Кейс 10. REST: интеграция внешней системы (ERP → Portal)

Заголовок раздела «Кейс 10. REST: интеграция внешней системы (ERP → Portal)»

См. также: Внешние интеграции.

Задача: nightly job ERP создаёт или обновляет элемент справочника «Контрагенты» в Portal.

Для расширений Portal (WebPart / Event Receiver / Timer Job) предпочтителен SDK-путь PortalListItem (кейсы 4–9). REST ниже — для внешних систем без C# SDK.

# 1. Схема полей (опционально; при записи допустим internal_name)
GET /api/v1/lists/{listId}/fields
Authorization: Bearer …
# 2. Найти контрагента по ИНН
GET /api/v1/lists/{listId}/items?filter={"logic":"and","conditions":[{"column":"inn","operator":"eq","value":"7707083893"}]}&limit=1
# 3a. Создать, если нет (ключи — UUID или internal_name)
POST /api/v1/lists/{listId}/items
Content-Type: application/json
{
"fieldValues": {
"title": "ООО Ромашка",
"inn": "7707083893",
"status": "Активен"
}
}
# 3b. Или обновить (merge — только изменённые поля)
PATCH /api/v1/lists/{listId}/items/42
Content-Type: application/json
{
"fieldValues": {
"status": "Архив"
}
}

Сущности: REST API; фильтр и запись по internal_name (или UUID).


Кейс 11. Связка module.json + код: передача id в WebPart

Заголовок раздела «Кейс 11. Связка module.json + код: передача id в WebPart»

Задача: при установке модуля страница уже содержит WebPart с привязкой к списку — без ручной настройки в UI.

Где: module.jsonpages[].zonesContent.

Источник: packages/modules/tasks/module.json

"pages": [
{
"slug": "home",
"node": { "$ref": "nodes.tasks" },
"zonesContent": [[{
"type": "webpart",
"definitionKey": "demo.tasks",
"properties": {
"tasksListId": { "$ref": "lists.tasks.id" },
"defaultViewMode": "week"
}
}]]
}
]

При установке $ref заменяется на UUID — WebPart сразу получает tasksListId в Property Pane.


СущностьProvisionerWebPartEvent ReceiverREST
Список (lists)GetListIdAsyncctx.Lists.*api.Lists.*/lists/{id}/items
БиблиотекаGetLibraryIdAsync, UploadLibraryFileAsyncчерез API файлов/libraries/{id}/files
Узелсоздаётся из module.jsonctx.Nodes.*api.Nodes.*/nodes
ПользовательEnsureLocalUserAsyncctx.Userиз payload события/admin/users
Праваpermissions в manifestпроверка ACL автоматическиот user события/permissions
МодульКейсы
demo.address-book1, 4 — иерархия lookup, чтение в WebPart
demo.tasks2, 5, 11 — person, создание из UI, pages
demo.meeting-room-booking6 — два списка, datetime, конфликты
demo.banners3 — библиотека + seed
demo.birthdays7 — cross-module requires

Скачать готовые .portalmod: korport.ru/modules.