Кейсы работы с сущностями Portal
Практические сценарии написания кода: списки, библиотеки, пользователи, связи между модулями. Примеры основаны на демо-модулях из репозитория — их можно собрать и установить как .portalmod.
Пошаговые гайды: работа с узлами, работа со списками, сценарии Event Receiver.
См. также: Значения полей списков, Функциональные модули, SDK provisioner.
Где выполняется код
Заголовок раздела «Где выполняется код»| Контекст | SDK | Ключи полей при записи | Права |
|---|---|---|---|
Provisioner (установка .portalmod) | Portal.Module.Sdk | internal_name (title, status…) | Системные (установка модуля) |
| WebPart (страница) | Portal.WebPart.Sdk | UUID полей из GetFieldsAsync | Текущий пользователь страницы |
| Event Receiver (событие списка) | Portal.EventReceiver.Sdk | UUID полей | Пользователь события |
| Timer Job (расписание) | Portal.TimerJob.Sdk | UUID полей | runAsUserId из config |
| REST API (внешняя система) | HTTP | UUID полей | Токен / сессия с нужными правами |
Паттерн WebPart / Event Receiver / Timer Job / REST: сначала GetFieldsAsync → словарь internal_name → fieldId → читать/писать field_values[fieldId].
Паттерн Provisioner: писать сразу по internal_name — платформа сама сопоставит поля схемы списка.
Кейс 1. Справочник с иерархией lookup
Заголовок раздела «Кейс 1. Справочник с иерархией lookup»Задача: при установке модуля создать подразделения с родительскими связями, затем сотрудников со ссылками на подразделение и должность.
Где: 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).
Кейс 4. WebPart: чтение списка и построение UI
Заголовок раздела «Кейс 4. WebPart: чтение списка и построение UI»Задача: отобразить оргструктуру — загрузить все подразделения, построить дерево, показать сотрудников выбранного отдела.
Где: WebPart «Адресная книга».
Источник: AddressBookWebPart.cs
// listId приходит из Property Pane (tasksListId / departmentsListId)var config = await LoadConfigAsync(ctx, ct); // проверка Guid из ctx.Properties
var items = await ctx.Lists.GetItemsAsync(config.EmployeesListId, limit: 500, ct);
foreach (var item in items){ var id = PortalItemJson.GetItemGuid(item); var dept = ReadLookupField(item, config.DepartmentFieldId); var name = ReadTextFieldById(item, config.FullNameFieldId); // … построение HTML}Паттерн: один раз загрузить GetFieldsAsync → сохранить UUID полей в конфиге WebPart → читать field_values[fieldId].
Кейс 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); // UUID полей из GetFieldsAsync
var fieldValues = new Dictionary<string, object?> { [config.TitleFieldId] = title, [config.StatusFieldId] = "Новая", [config.StartFieldId] = start.ToString("yyyy-MM-dd"), [config.AssignedToFieldId] = new Dictionary<string, object?> { ["principalType"] = "user", ["id"] = userId.ToString(), ["display"] = displayName, }, };
await ctx.Lists.CreateItemAsync(config.TasksListId, fieldValues, 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 на комнату (UUID полей)[fieldValues] = { [config.RoomFieldId] = new { itemId = roomId.ToString(), display = room.Title }, [config.StartFieldId] = start.ToString("O"), ...};await ctx.Lists.CreateItemAsync(config.BookingsListId, fieldValues, 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 $ref → modules.{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){ var configuredListId = GetConfigGuid(context.ReceiverConfig, "listId"); var eventListId = GetDetailsGuid(context.Details, "listId");
// Обрабатываем только «свой» список if (configuredListId != Guid.Empty && eventListId != configuredListId) return new { skipped = true };
if (context.EventName == "listItem.updated") { var itemRef = context.Details.GetProperty("item").GetProperty("id").GetRawText(); await api.Lists.GetItemAsync(eventListId.ToString(), itemRef, ct); // … UpdateItemAsync, постановка job и т.д. }
return new { ok = true };}События: listItem.created, listItem.updated, listItem.deleted.
Event Receiver можно объявить в module.json модуля — при установке .portalmod привязка создаётся автоматически.
Кейс 9. Timer Job: периодическая обработка списка
Заголовок раздела «Кейс 9. Timer Job: периодическая обработка списка»Задача: каждую ночь найти просроченные задачи и перевести статус в «Просрочена».
Где: .portaltimer, config экземпляра с runAsUserId и tasksListId.
public async Task<object?> ExecuteAsync(TimerJobContext context, ITimerJobApi api, CancellationToken ct){ var listId = context.Config.GetProperty("tasksListId").GetString()!;
var fields = (JsonElement)await api.Lists.GetFieldsAsync(listId, ct); var statusFieldId = FieldId(fields, "status"); var dueFieldId = FieldId(fields, "due_at");
var items = (JsonElement)await api.Lists.GetItemsAsync(listId, limit: 1000, ct: ct); var updated = 0;
foreach (var item in items.EnumerateArray()) { var dueRaw = item.GetProperty("field_values").GetProperty(dueFieldId).GetString(); if (DateOnly.TryParse(dueRaw, out var due) && due < DateOnly.FromDateTime(DateTime.Today)) { var itemId = item.GetProperty("id").GetRawText(); await api.Lists.UpdateItemAsync(listId, itemId, new Dictionary<string, object?> { [statusFieldId] = "Просрочена", }, ct); updated++; } }
return new { updated };}Сущности: Timer Job instance; права задаются через runAsUserId (нужен пользователь с edit на список).
Кейс 10. REST: интеграция внешней системы (ERP → Portal)
Заголовок раздела «Кейс 10. REST: интеграция внешней системы (ERP → Portal)»См. также: Внешние интеграции.
Задача: nightly job ERP создаёт или обновляет элемент справочника «Контрагенты» в Portal.
# 1. Получить UUID полей (один раз, кешировать)GET /api/v1/lists/{listId}/fieldsAuthorization: Bearer …
# 2. Найти контрагента по ИННGET /api/v1/lists/{listId}/items?filter={"logic":"and","conditions":[{"column":"inn","operator":"eq","value":"7707083893"}]}&limit=1
# 3a. Создать, если нетPOST /api/v1/lists/{listId}/itemsContent-Type: application/json
{ "fieldValues": { "uuid-title": "ООО Ромашка", "uuid-inn": "7707083893", "uuid-status": "Активен" }}
# 3b. Или обновить (merge — только изменённые поля)PATCH /api/v1/lists/{listId}/items/42Content-Type: application/json
{ "fieldValues": { "uuid-status": "Архив" }}Сущности: REST API; фильтр по internal_name; запись по UUID полей.
Кейс 11. Связка module.json + код: передача id в WebPart
Заголовок раздела «Кейс 11. Связка module.json + код: передача id в WebPart»Задача: при установке модуля страница уже содержит WebPart с привязкой к списку — без ручной настройки в UI.
Где: module.json → pages[].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.
Сводка: какую сущность где использовать
Заголовок раздела «Сводка: какую сущность где использовать»| Сущность | Provisioner | WebPart | Event Receiver | REST |
|---|---|---|---|---|
Список (lists) | GetListIdAsync | ctx.Lists.* | api.Lists.* | /lists/{id}/items |
| Библиотека | GetLibraryIdAsync, UploadLibraryFileAsync | через API файлов | — | /libraries/{id}/files |
| Узел | создаётся из module.json | ctx.Nodes.* | api.Nodes.* | /nodes |
| Пользователь | EnsureLocalUserAsync | ctx.User | из payload события | /admin/users |
| Права | permissions в manifest | проверка ACL автоматически | от user события | /permissions |
Демо-модули для экспериментов
Заголовок раздела «Демо-модули для экспериментов»| Модуль | Кейсы |
|---|---|
| demo.address-book | 1, 4 — иерархия lookup, чтение в WebPart |
| demo.tasks | 2, 5, 11 — person, создание из UI, pages |
| demo.meeting-room-booking | 6 — два списка, datetime, конфликты |
| demo.banners | 3 — библиотека + seed |
| demo.birthdays | 7 — cross-module requires |
Скачать готовые .portalmod: korport.ru/modules.