Хранение и отображение истории работы с объектом

Одним из важнейших элементов в работе с системой является возможность предоставления пользователю информации о накопленной истории работы с сущностями. В эту историю могут входить различные действия с объектом и\или связанные объекты системы.

Очень важно! Необходимо определиться с желаемым набором данных в истории до начала реализации. В дальнейшем вносить серьезные изменения будет сложнее. Также важно понимать, что данная информация накапливается в базе данных, поэтому не стоит обрабатывать в истории очень частые операции, например, просмотр объекта (для этого гораздо лучше завести отдельный журнал\сущность и показывать в отчете).

Настройка сущности

Для начала необходимо настроить сущность в Дизайнере. Для этого откройте ее карточку и на вкладке Дополнительные установите флажок Хранить историю изменения, как показано на рисунке:

Далее обязательно настройте доступные действия с сущностью.

Обращайте внимание на иконки, прикрепленные к действиям: они будут использованы в отображении истории.

Жизненный цикл работы механизма истории

На рисунке ниже в схематичном виде изображен жизненный цикл событий в механизме истории.

Ниже приведена схема данных, используемая для хранения истории в базе (сущность IEntityActionHistory).

Реализация сохранения событий истории

После настройки сущности необходимо реализовать обработку и сохранение события в базу данных. Делается это при помощи точки расширения EleWise.ELMA.Events.Audit.IEntityActionEventAggregator.

/// <summary>
/// Точка расширения для аггрегации событий объекта
/// </summary>
[ExtensionPoint(ServiceScope.Shell)]
public interface IEntityActionEventAggregator
{
    /// <summary>
    /// Аггрегировать события в списке (убрать дублирующие, объединить общие события)
    /// </summary>
    /// <param name="eventList">Текущий список событий в рамках транзакции</param>
    /// <param name="previousResults">Список результатов выполнения предыдущих аггрегаторов</param>
    /// <returns>Результаты выполнения</returns>
    IEnumerable<ActionEventAggregatorResult> Aggregate(IList<EntityActionEventArgs> eventList, IEnumerable<ActionEventAggregatorResult> previousResults);
}

Обработка данных представляет собой подготовку событий типа EleWise.ELMA.Model.Events.EntityActionEventArgs к сохранению (аггрегация, объединение и удаление лишних событий). При этом вы должны помнить, что 3 базовых события Создание, Обновление и Удаление всегда записываются в базу.

Сохранение простых событий

Для сохранения простых событий, которые не влекут за собой прямого изменения свойств сущности можно создавать простой компонент на основе точки расширения. Например, для сохранения информации о действии Добавление комментария в сущности Событие можно использовать такой код:

/// <summary>
/// Оптимизатор событий для События в календаре, обрабатывающий простые действия
/// </summary>
[Component]
internal class CalendarEventSimpleActionsEventAggregator : IEntityActionEventAggregator
{
    public IEnumerable<ActionEventAggregatorResult> Aggregate(IList<EntityActionEventArgs> eventList, IEnumerable<ActionEventAggregatorResult> previousResults)
    {
        return eventList.Where(e => e.Action.Uid == CalendarEventActions.AddCommentGuid).Select(e => new ActionEventAggregatorResult(this, e, true)).ToList();
    }
}

В данном коде мы проверили на соответствие идентификатор типа действия и идентификатор действия события Добавление комментария, созданного ранее в Дизайнере.

Сохранение событий содержащих изменения

Большинство событий так или иначе связаны с изменением состояния модели, например, изменение статуса задачи при выполнении действия Начало работы или изменении исполнителя при переназначении. Обрабатывать такие события лучше всего при помощи вспомогательного базового класса EleWise.ELMA.Events.Audit.Impl.BaseEntityUpdateEventAggregator.

Например, для отслеживания и сохранения событий Изменение времени и Завершение необходимо сделать следующую реализацию:

/// <summary>
/// Оптимизатор событий для События в календаре, обрабатывающий действия связанные с изменением полей
/// </summary>
[Component]
internal class CalendarEventBaseEditActionsEventAggregator : BaseEntityUpdateEventAggregator
{
    #region Overrides of BaseEntityUpdateEventAggregator

    /// <summary>
    /// Список идентификаторов действий, которые должны быть обработаны данным аггрегатором
    /// </summary>
    protected override IEnumerable<Guid> ProcessedActions
    {
        get
        {
            yield return CalendarEventActions.ChangeTimeGuid;
            yield return CalendarEventActions.CompleteGuid;
        }
    }

    #endregion
}

Сам по себе базовый класс реализует проверки на совпадение по идентификатору действия и предотвращает дублирующие сохранения одинаковых действий с одним объектом. Также он находит в потоке событий базовое событие EleWise.ELMA.Model.Actions.DefaultEntityActions.Update, достает оттуда данные до изменения и присваивает их в найденное событие.

Все операции базового класса могут быть гибко переопределены при необходимости.


Собственная реализация событий для истории

В основе системы событий лежит класс EleWise.ELMA.Model.Events.EntityActionEventArgs, а также его наследники. В данный момент существует только один наследник - это класс, описывающий системное событие обновления сущности EleWise.ELMA.Model.Events.EditEntityActionEventArgs. При сохранении событий в базу данных на основе этих классов генерируются сущности типа EleWise.ELMA.Common.ModelsIEntityActionHistory.

Вам может понадобится сохранить какие-то дополнительные данные для своего события, в этом случае необходимо выполнить следующие действия:

  • реализовать наследника от класса EntityActionEventArgs;
  • пометить его служебным атрибутом EleWise.ELMA.Model.Attributes.UidAttribute;
  • в наследнике переопределить методы byte[] GetAdditionalData() и SetAdditionalData(byte[] data).

Данные, возвращенные в методе GetAdditionalData, будут сохранены в исходном виде в базу данных и затем, при восстановлении события, будут переданы в метод SetAdditionalData, поэтому при внесении изменений в данные методы всегда помните об обратной совместимости. Атрибут UidAttribute должен принимать уникальный идентификатор данного типа события, чтобы можно было восстановить данные из базы.

Реализация отображения истории

Одним из самых важных моментов является реализация визуального отображения истории работы с сущностью. Для этого используется точка расширения EleWise.ELMA.Web.Mvc.ExtensionPoints.IAuditEventRender или более простой и правильный способ – наследование от базового класса EleWise.ELMA.Web.Mvc.Models.History.BaseAuditEventRender.


Определение моделей для представления действий

Сначала необходимо в серверном компоненте определить модели для представлений, которые будут использоваться при отображении истории. Эти модели должны быть унаследованы от базового класса EleWise.ELMA.Common.Models.HistoryBaseModel. Также для правильного отображения блоков истории необходимо наследоваться от соответствующих интерфейсов.

/// <summary>
/// Модель отображения истории работы с Событием в каледнаре для действия Добавление комментария
/// </summary>
public class CommentCalendarEventHistoryModel : HistoryBaseModel, ICommentedHistoryModel, IAttachedHistoryModel, ICalendarEventHistoryModel
{
    /// <summary>
    /// Конструктор
    /// </summary>
    /// <param name="originalEvent">Событие</param>
    /// <param name="actionTheme">Тема</param>
    public CommentCalendarEventHistoryModel(EntityActionEventArgs originalEvent, string actionTheme) 
        : base(originalEvent, actionTheme)
    {
    }

    /// <summary>
    /// Связанный комментарий
    /// </summary>
    public IComment Comment { get; set; }

    /// <summary>
    /// Список связанных вложений
    /// </summary>
    public ICollection<IAttachment> Attachments { get; set; }
}

Обратите внимание на интерфейсы, от которых наследуется данный класс. Среди них есть ICalendarEventHistoryModel – это пустой интерфейс из модуля Календарь, он понадобится нам в дальнейшем, чтобы определить тип класса модели отображения. Остальные интерфейсы наследуются от основного интерфейса EleWise.ELMA.Events.Audit.IHistoryBaseModel, чтобы однозначно определить свое предназначение.

О блоках и создании своих блоков будет написано ниже в статье.

На данный момент существуют блоки:

  • Комментарий (EleWise.ELMA.Common.Models.ICommentedHistoryModel);
  • Вложения (EleWise.ELMA.Common.Models.IAttachedHistoryModel);
  • Вопросы / Ответы (EleWise.ELMA.Tasks.Models.IQuestionedHistoryModel).


Сбор дополнительной информации для отображения

Для сбора дополнительной информации, которая не была учтена при сохранении основной истории, можно воспользоваться точкой расширения EleWise.ELMA.Events.Audit.IEntityActionHistoryCollector данная точка содержит всего один метод:

/// <summary>
/// Получить данные для отображения истории
/// </summary>
/// <param name="id">Идентификатор объекта</param>
/// <param name="actionObject">Идентификатор типа сущности</param>
/// <returns>Список аргументов для добавления в общий список отображения</returns>
IEnumerable<EntityActionEventArgs> CollectHistory(long id, Guid actionObject);

В реализации этого метода необходимо вернуть для объекта список событий на обработку. Например, для реализации отображения вопросов в задачах сделана следующая реализация:

/// <summary>
/// Сборщик дополнительных данных для отображения вопросов в истории по задаче.
/// </summary>
[Component]
public class TaskQuestionHistoryCollector : IEntityActionHistoryCollector
{
    public IEntityActionHistoryEventService HistoryEventService { get; set; }

    #region Implementation of IEntityActionHistoryCollector

    public IEnumerable<EntityActionEventArgs> CollectHistory(long id, Guid actionObject)
    {
        var result = new List<EntityActionEventArgs>();
        var entityMetadata = MetadataServiceContext.Service.GetMetadata(actionObject) as EntityMetadata;
        if (entityMetadata != null)
        {
            var classes = MetadataLoader.GetBaseClasses(entityMetadata);
            classes.Add(entityMetadata);

            if (classes.Any(c => c.Uid == InterfaceActivator.UID<ITaskBase>()))
            {
                var questions = QuestionManager.Instance.GetQuestions(id, actionObject);

                foreach (var question in questions)
                {
                    if (question == null) continue;

                    var @event = EntityActionEventArgs.TryCreate(null, question, DefaultEntityActions.CreateGuid);

                    if (@event != null)
                    {
                        @event.ActionDate = question.CreationDate.HasValue ? question.CreationDate.Value : DateTime.Now;
                        @event.ActionAuthor = question.CreationAuthor;
                        @event.UnitOfWorkUid = Guid.Empty;

                        @event.ExtendedProperties[Common.Managers.EntityActionHistoryManager.ExtendedProperties_Uid] = Guid.Empty;
                        @event.ExtendedProperties[Common.Managers.EntityActionHistoryManager.ExtendedProperties_SessionUid] = null;

                        result.Add(@event);
                    }
                }
            }
        }

        return result;
    }

    #endregion
}

В дальнейшем эти события просто обрабатываются и на их основе формируются данные для блоков отображения.

Реализация провайдера отображения блока истории

Теперь необходимо сообщить механизму отображения о тех блоках, которые вы будете показывать в своей истории. Для этого необходимо реализовать точку расширения EleWise.ELMA.Web.Mvc.ExtensionPoints.IHistoryPartProvider или создать наследника от одного из базовых типов блоков отображения.

  • EleWise.ELMA.BPM.Web.Common.Components.CommentHistoryPartProviderBase – базовый провайдер для отображения блоков с комментариями;
  • EleWise.ELMA.BPM.Web.Common.Components.AttachmentHistoryPartProviderBase – базовый провайдер для отображения блоков с вложениями;
  • EleWise.ELMA.BPM.Web.Tasks.Components.QuestionHistoryPartProviderBase – базовый провайдер для отображения блоков с вопросам \ ответами.

Мы создадим наследников от провайдера комментариев и от провайдера вложений

/// <summary>
/// Провайдер для блока истории действий с объектом. 
/// Добавляет блок с отображением комментария в сущность Событие календаря
/// </summary>
[Component]
public class CalendarEventCommentHistoryPartProvider : CommentHistoryPartProviderBase
{
    #region Overrides of CommentHistoryPartProviderBase

    /// <summary>
    /// Необходимо проверить в наследнике сущность для которой будет осуществляться отображение.
    /// После проверки кнопка "Комментарии" будет добавлена на панель истории
    /// </summary>
    /// <param name="html"></param>
    /// <param name="entity">Сущность</param>
    /// <returns><c>true</c>, если сущность поддерживается и для нее необходимо добавить кнопку на панель истории</returns>
    protected override bool CheckEntity(HtmlHelper html, IEntity entity)
    {
        return entity is ICalendarEvent;
    }

    /// <summary>
    /// Необходимо проверить в наследнике сформированную модель данных истории для отображения.
    /// Если в данном контексте модель поддерживается, то блок будет отправлен на отображение
    /// </summary>
    /// <param name="html"></param>
    /// <param name="eventData">Данные для отображения</param>
    /// <returns><c>true</c>, если модель данных поддерживается и можно ее отображать</returns>
    protected override bool CheckEventActionObject(HtmlHelper html, ICommentedHistoryModel eventData)
    {
        return eventData is ICalendarEventHistoryModel;
    }

    #endregion
}

Аналогично реализуется наследник от базового класса провайдера вложений AttachmentHistoryPartProviderBase. Тут же в реализации мы видим, каким образом используется базовый интерфейс ICalendarEventHistoryModel.

Обратите внимание на то, что данный провайдер является точкой расширения и его реализации вызываются каждый раз при отрисовке панели истории и для всех блоков данных, именно поэтому необходимо тут тщательно фильтровать те данные, которые нужно отображать в вашем контексте, иначе они будут появляться или дублироваться в других панелях истории.

Обработка данных и формирование модели представления

Далее идет реализация обработки и подготовки данных для отображения в истории, для этого необходимо создать наследника от базового классаEleWise.ELMA.Web.Mvc.Models.History.BaseAuditEventRender в веб-компоненте вашего модуля.
/// <summary>
/// Обработчик отображения истории для сущности События календаря
/// </summary>
[Component(Order = 100)]
public class CalendarEventSimpleEventRender : BaseAuditEventRender
{
    /// <summary>
    /// Список идентификаторов действий, которые может выводить данный обработчик
    /// </summary>
    protected override IEnumerable<Guid> Actions
    {
        get
        {
            yield return CalendarEventActions.AddCommentGuid;
            yield return CalendarEventActions.EditGuid;
        }
    }

    /// <summary>
    /// Список идентификаторов типов объектов, которые может выводить данный обработчик
    /// </summary>
    protected override IEnumerable<Guid> Objects
    {
        get { yield return EleWise.ELMA.Model.Services.InterfaceActivator.UID<ICalendarEvent>(); }
    }

    #region Implementation of IAuditEventRender
        
    /// <summary>
    /// Получить модель данных для элемента отображения истории
    /// </summary>
    /// <param name="html">Хелпер</param>
    /// <param name="event">Событие</param>
    /// <param name="historyLoader">Загрузчик истории</param>
    /// <returns></returns>
    protected override IHistoryBaseModel CreateEventData(HtmlHelper html, EntityActionEventArgs @event, IEntityActionHistoryLoader historyLoader)
    {
        if (html == null) throw new ArgumentNullException("html");
        if (@event == null) throw new ArgumentNullException("event");
        if (historyLoader == null) throw new ArgumentNullException("historyLoader");

        if (@event.Action.Uid == CalendarEventActions.EditGuid)
            return RenderUserEdit(html, @event, historyLoader);

        if (@event.Action.Uid == CalendarEventActions.AddCommentGuid)
            return RenderAddComment(html, @event, historyLoader);

        return null;
    }

    private IHistoryBaseModel RenderUserEdit(HtmlHelper html, EntityActionEventArgs @event, IEntityActionHistoryLoader historyLoader)
    {
        if (historyLoader == null) throw new ArgumentNullException("historyLoader");

        var userEditEvent = new EditCalendarEventHistoryModel(@event, EleWise.ELMA.SR.T("Событие изменено"));

        var calendarEvent = (ICalendarEvent)@event.New;

        var editEvent = historyLoader.LoadHistory(
            @event.UnitOfWorkUid,
            EleWise.ELMA.Model.Services.InterfaceActivator.UID<ICalendarEvent>(),
            EleWise.ELMA.Model.Actions.DefaultEntityActions.UpdateGuid,
            calendarEvent.Id).FirstOrDefault();

        if (editEvent != null)
        {
            userEditEvent.OldEntity = (ICalendarEvent)editEvent.Old;
            userEditEvent.NewEntity = (ICalendarEvent)editEvent.New;

            var editEventArgs = editEvent as EditEntityActionEventArgs;
            if (editEventArgs != null)
            {
                userEditEvent.ChangedProperties = editEventArgs.ChangedProperties.ToList();
            }
        }

        var commentEvents = historyLoader.LoadHistory(
            @event.UnitOfWorkUid,
            EleWise.ELMA.Model.Services.InterfaceActivator.UID<EleWise.ELMA.Common.Models.IComment>(),
            EleWise.ELMA.Model.Actions.DefaultEntityActions.CreateGuid);

        var comment = commentEvents
            .Select(e => e.New)
            .Cast<EleWise.ELMA.Common.Models.IComment>()
            .FirstOrDefault();

        userEditEvent.Comment = comment;

        var attachEvents = historyLoader.LoadHistory(
            @event.UnitOfWorkUid,
            EleWise.ELMA.Model.Services.InterfaceActivator.UID<EleWise.ELMA.Common.Models.IAttachment>(),
            EleWise.ELMA.Model.Actions.DefaultEntityActions.CreateGuid);

        var attaches = attachEvents
            .Select(e => e.New)
            .Cast<EleWise.ELMA.Common.Models.IAttachment>()
            .ToList();

        userEditEvent.Attachments = attaches;

        return userEditEvent;
    }

    private IHistoryBaseModel RenderAddComment(HtmlHelper html, EntityActionEventArgs @event, IEntityActionHistoryLoader historyLoader)
    {
        if (historyLoader == null) throw new ArgumentNullException("historyLoader");

        var commentEvent = new CommentCalendarEventHistoryModel(@event, EleWise.ELMA.SR.T("Добавлен комментарий"));

        var commentEvents = historyLoader.LoadHistory(
            @event.UnitOfWorkUid,
            EleWise.ELMA.Model.Services.InterfaceActivator.UID<EleWise.ELMA.Common.Models.IComment>(),
            EleWise.ELMA.Model.Actions.DefaultEntityActions.CreateGuid);

        var comment = commentEvents
            .Select(e => e.New)
            .Cast<EleWise.ELMA.Common.Models.IComment>()
            .FirstOrDefault();

        commentEvent.Comment = comment;

        var attachEvents = historyLoader.LoadHistory(
            @event.UnitOfWorkUid,
            EleWise.ELMA.Model.Services.InterfaceActivator.UID<EleWise.ELMA.Common.Models.IAttachment>(),
            EleWise.ELMA.Model.Actions.DefaultEntityActions.CreateGuid);

        var attaches = attachEvents
            .Select(e => e.New)
            .Cast<EleWise.ELMA.Common.Models.IAttachment>()
            .ToList();

        commentEvent.Attachments = attaches;

        return commentEvent;
    }

    /// <summary>
    /// Получить дополнительный блок для отображения в истории.
    /// Например блок с данными об изменениях или другую информацию
    /// </summary>
    /// <returns>Блок для добавления в отображение, иначе <c>null</c></returns>
    protected override HistoryPartViewBlock GetExtraViewBlock(EntityActionEventArgs @event)
    {
        if (@event.Action.Uid == CalendarEventActions.EditGuid)
            return EditViewBlock();

        return null;
    }

    private HistoryPartViewBlock EditViewBlock()
    {
        return new HistoryPartViewBlock
        {
            HistoryPartType = "action",
            Index = 1,
            RenderDelegate = (html, model) => html.Partial("AuditView/CalendarEvent.Edit", model)
        };
    }

    #endregion
}

Как можно увидеть, тут происходит довольно много действий. Основное предназначение данного класса – это сформировать правильные данные для отображения блока истории на основе восстановленного из базы аргумента события.

Конечно, вы всегда можете напрямую унаследоваться от интерфейса точки расширения EleWise.ELMA.Web.Mvc.ExtensionPoints.IAuditEventRender и реализовать полностью свою логику для отображения элемента истории, но крайне не рекомендуется этого делать, пока вы не будете уверены в своих действиях.


Добавление нестандартных блоков в историю

Иногда может потребоваться добавить какие-то нестандартные блоки в историю либо кнопки на панель истории, для этой цели можно использовать EleWise.ELMA.Web.Mvc.ExtensionPoints.IHistoryPartProvider, реализовав на его основе собственный базовый класс для добавления блоков и кнопок. Вот, например, реализация базового класса для вывода блока с комментарием:
/// <summary>
/// Базовый провайдер для блока истории действий с объектом. 
/// Добавляет кнопку в панель и блок с отображением комментария
/// </summary>
public abstract class CommentHistoryPartProviderBase : IHistoryPartProvider
{
    public const string HistoryPartType = "comment";

    /// <summary>
    /// Получить набор кнопок для панели истории
    /// </summary>
    /// <param name="html"></param>
    /// <param name="entity">Сущность, для которой выводится история</param>
    /// <returns></returns>
    public virtual IEnumerable<HistoryPartButton> GetButtons(HtmlHelper html, IEntity entity)
    {
        if (CheckEntity(html, entity))
        {
            yield return new HistoryPartButton
                                {
                                    HistoryPartType = HistoryPartType,
                                    ImageUrl = html.Url().Image("#x16/add_comment.gif"),
                                    Index = 0,
                                    Text = SR.T("Комментарии")
                                };
        }
    }

    /// <summary>
    /// Необходимо проверить в наследнике сущность для которой будет осуществляться отображение.
    /// После проверки кнопка "Комментарии" будет добавлена на панель истории
    /// </summary>
    /// <param name="html"></param>
    /// <param name="entity">Сущность</param>
    /// <returns><c>true</c>, если сущность поддерживается и для нее необходимо добавить кнопку на панель истории</returns>
    protected abstract bool CheckEntity(HtmlHelper html, IEntity entity);

    protected virtual MvcHtmlString RenderDelegate(HtmlHelper html, IHistoryBaseModel eventData)
    {
        if (eventData is ICommentedHistoryModel)
        {
            var comment = ((ICommentedHistoryModel)eventData).Comment;
            if(comment != null)
            {
                return html.Partial("HistoryParts/Comment", eventData);
            }
        }

        return null;
    }

    /// <summary>
    /// Получить набор блоков для модели истории
    /// </summary>
    /// <param name="html"></param>
    /// <param name="eventData">Данные одного элемента отображения истории</param>
    /// <returns></returns>
    public virtual IEnumerable<HistoryPartViewBlock> GetBlocks(HtmlHelper html, IHistoryBaseModel eventData)
    {
        if (CheckEventData(html, eventData))
        {
            yield return new HistoryPartViewBlock
                                {
                                    HistoryPartType = HistoryPartType,
                                    Index = 0,
                                    RenderDelegate = RenderDelegate
                                };
        }
    }

    /// <summary>
    /// Проверить данные модели представления на соответствие типу
    /// </summary>
    /// <param name="html"></param>
    /// <param name="eventData">Данные модели представления</param>
    /// <returns><c>true</c>, если данные могут быть выведены в историю</returns>
    protected virtual bool CheckEventData(HtmlHelper html, IHistoryBaseModel eventData)
    {
        var attachedData = eventData as ICommentedHistoryModel;
        return attachedData != null && CheckEventActionObject(html, attachedData);
    }

    /// <summary>
    /// Необходимо проверить в наследнике сформированную модель данных истории для отображения.
    /// Если в данном контексте модель поддерживается, то блок будет отправлен на отображение
    /// </summary>
    /// <param name="html"></param>
    /// <param name="eventData">Данные для отображения</param>
    /// <returns><c>true</c>, если модель данных поддерживается и можно ее отображать</returns>
    protected abstract bool CheckEventActionObject(HtmlHelper html, ICommentedHistoryModel eventData);
}

Обратите внимание на то, что при реализации и использовании блоков истории типы в элементах HistoryPartViewBlock.HistoryPartType и HistoryPartButton.HistoryPartType должны совпадать. Они служат для семантической разметки блоков истории. Ниже приведен код для разметки представления HistoryParts/Comment из реализации блока:
@model EleWise.ELMA.Common.Models.ICommentedHistoryModel
<div class="history-type-comment history-item-ext">
<div class="history-comment-content">
<div class="history-comment-angle"></div>
@Html.Display(m => m.Comment.Text)
</div>
</div>