From c7b9a57795abfe051f12cf7b1143f1ed8043d76f Mon Sep 17 00:00:00 2001 From: Shannon Date: Thu, 7 Sep 2017 17:27:37 +1000 Subject: [PATCH] U4-10382 Add end point to get a paginated audit trail --- src/Umbraco.Core/Models/AuditItem.cs | 38 +++++++- src/Umbraco.Core/Models/IAuditItem.cs | 14 +++ src/Umbraco.Core/Models/Rdbms/LogDto.cs | 2 +- .../Models/Rdbms/ReadOnlyLogDto.cs | 41 +++++++++ .../Persistence/Factories/MacroFactory.cs | 1 + .../Persistence/Mappers/AuditMapper.cs | 46 ++++++++++ .../Repositories/AuditRepository.cs | 88 ++++++++++++++----- .../Interfaces/IAuditRepository.cs | 10 ++- src/Umbraco.Core/Services/AuditService.cs | 25 ++++++ src/Umbraco.Core/Services/IAuditService.cs | 20 +++++ src/Umbraco.Core/Umbraco.Core.csproj | 3 + .../Models/ContentEditing/AuditLog.cs | 19 ++-- .../Models/ContentEditing/AuditLogType.cs | 9 +- ...ardModelsMapper.cs => MiscModelsMapper.cs} | 11 ++- src/Umbraco.Web/Umbraco.Web.csproj | 2 +- src/umbraco.businesslogic/Log.cs | 44 +++------- 16 files changed, 302 insertions(+), 71 deletions(-) create mode 100644 src/Umbraco.Core/Models/IAuditItem.cs create mode 100644 src/Umbraco.Core/Models/Rdbms/ReadOnlyLogDto.cs create mode 100644 src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs rename src/Umbraco.Web/Models/Mapping/{DashboardModelsMapper.cs => MiscModelsMapper.cs} (60%) diff --git a/src/Umbraco.Core/Models/AuditItem.cs b/src/Umbraco.Core/Models/AuditItem.cs index 7aff3a4f68..7e019fa4af 100644 --- a/src/Umbraco.Core/Models/AuditItem.cs +++ b/src/Umbraco.Core/Models/AuditItem.cs @@ -2,18 +2,54 @@ namespace Umbraco.Core.Models { - public sealed class AuditItem : Entity, IAggregateRoot + public sealed class AuditItem : Entity, IAuditItem { + /// + /// Constructor for creating an item to be created + /// + /// + /// + /// + /// public AuditItem(int objectId, string comment, AuditType type, int userId) { + DisableChangeTracking(); + Id = objectId; Comment = comment; AuditType = type; UserId = userId; + + EnableChangeTracking(); + } + + /// + /// Constructor for creating an item that is returned from the database + /// + /// + /// + /// + /// + /// + /// + public AuditItem(int objectId, string comment, AuditType type, int userId, string userName, string userAvatar) + { + DisableChangeTracking(); + + Id = objectId; + Comment = comment; + AuditType = type; + UserId = userId; + UserName = userName; + UserAvatar = userAvatar; + + EnableChangeTracking(); } public string Comment { get; private set; } public AuditType AuditType { get; private set; } public int UserId { get; private set; } + public string UserName { get; private set; } + public string UserAvatar { get; private set; } } } \ No newline at end of file diff --git a/src/Umbraco.Core/Models/IAuditItem.cs b/src/Umbraco.Core/Models/IAuditItem.cs new file mode 100644 index 0000000000..4e4a39ab59 --- /dev/null +++ b/src/Umbraco.Core/Models/IAuditItem.cs @@ -0,0 +1,14 @@ +using System; +using Umbraco.Core.Models.EntityBase; + +namespace Umbraco.Core.Models +{ + public interface IAuditItem : IAggregateRoot + { + string Comment { get; } + AuditType AuditType { get; } + int UserId { get; } + string UserName { get; } + string UserAvatar { get; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Rdbms/LogDto.cs b/src/Umbraco.Core/Models/Rdbms/LogDto.cs index be67b5873a..08fc602152 100644 --- a/src/Umbraco.Core/Models/Rdbms/LogDto.cs +++ b/src/Umbraco.Core/Models/Rdbms/LogDto.cs @@ -32,6 +32,6 @@ namespace Umbraco.Core.Models.Rdbms [Column("logComment")] [NullSetting(NullSetting = NullSettings.Null)] [Length(4000)] - public string Comment { get; set; } + public string Comment { get; set; } } } \ No newline at end of file diff --git a/src/Umbraco.Core/Models/Rdbms/ReadOnlyLogDto.cs b/src/Umbraco.Core/Models/Rdbms/ReadOnlyLogDto.cs new file mode 100644 index 0000000000..0de10333f8 --- /dev/null +++ b/src/Umbraco.Core/Models/Rdbms/ReadOnlyLogDto.cs @@ -0,0 +1,41 @@ +using System; +using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.DatabaseAnnotations; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; + +namespace Umbraco.Core.Models.Rdbms +{ + /// + /// object used for returning data from the umbracoLog table + /// + [TableName("umbracoLog")] + [PrimaryKey("id")] + [ExplicitColumns] + internal class ReadOnlyLogDto + { + [Column("id")] + [PrimaryKeyColumn] + public int Id { get; set; } + + [Column("userId")] + public int UserId { get; set; } + + [Column("NodeId")] + public int NodeId { get; set; } + + [Column("Datestamp")] + public DateTime Datestamp { get; set; } + + [Column("logHeader")] + public string Header { get; set; } + + [Column("logComment")] + public string Comment { get; set; } + + [ResultColumn("userName")] + public string UserName { get; set; } + + [ResultColumn("userAvatar")] + public string UserAvatar { get; set; } + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Factories/MacroFactory.cs b/src/Umbraco.Core/Persistence/Factories/MacroFactory.cs index 02d95b3776..964668e854 100644 --- a/src/Umbraco.Core/Persistence/Factories/MacroFactory.cs +++ b/src/Umbraco.Core/Persistence/Factories/MacroFactory.cs @@ -5,6 +5,7 @@ using Umbraco.Core.Models.Rdbms; namespace Umbraco.Core.Persistence.Factories { + internal class MacroFactory { public IMacro BuildEntity(MacroDto dto) diff --git a/src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs b/src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs new file mode 100644 index 0000000000..9d9de3f2ce --- /dev/null +++ b/src/Umbraco.Core/Persistence/Mappers/AuditMapper.cs @@ -0,0 +1,46 @@ +using System.Collections.Concurrent; +using Umbraco.Core.Models; +using Umbraco.Core.Models.Rdbms; +using Umbraco.Core.Persistence.SqlSyntax; + +namespace Umbraco.Core.Persistence.Mappers +{ + [MapperFor(typeof(AuditItem))] + [MapperFor(typeof(IAuditItem))] + public sealed class AuditMapper : BaseMapper + { + private static readonly ConcurrentDictionary PropertyInfoCacheInstance = new ConcurrentDictionary(); + + public AuditMapper(ISqlSyntaxProvider sqlSyntax) : base(sqlSyntax) + { + + } + + //NOTE: its an internal class but the ctor must be public since we're using Activator.CreateInstance to create it + // otherwise that would fail because there is no public constructor. + public AuditMapper() + { + BuildMap(); + } + + #region Overrides of BaseMapper + + internal override ConcurrentDictionary PropertyInfoCache + { + get { return PropertyInfoCacheInstance; } + } + + internal override void BuildMap() + { + if (PropertyInfoCache.IsEmpty) + { + CacheMap(src => src.Id, dto => dto.NodeId); + CacheMap(src => src.CreateDate, dto => dto.Datestamp); + CacheMap(src => src.UserId, dto => dto.UserId); + CacheMap(src => src.AuditType, dto => dto.Header); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Repositories/AuditRepository.cs b/src/Umbraco.Core/Persistence/Repositories/AuditRepository.cs index d425755579..23844952f1 100644 --- a/src/Umbraco.Core/Persistence/Repositories/AuditRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/AuditRepository.cs @@ -1,29 +1,58 @@ using System; using System.Collections.Generic; +using System.Linq; using Umbraco.Core.Logging; using Umbraco.Core.Models; +using Umbraco.Core.Models.Membership; using Umbraco.Core.Models.Rdbms; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.SqlSyntax; using Umbraco.Core.Persistence.UnitOfWork; namespace Umbraco.Core.Persistence.Repositories { - internal class AuditRepository : PetaPocoRepositoryBase, IAuditRepository + internal class AuditRepository : PetaPocoRepositoryBase, IAuditRepository { public AuditRepository(IScopeUnitOfWork work, CacheHelper cache, ILogger logger, ISqlSyntaxProvider sqlSyntax) : base(work, cache, logger, sqlSyntax) { } - protected override void PersistNewItem(AuditItem entity) + public IEnumerable GetPagedResultsByQuery(IQuery query, long pageIndex, int pageSize, out long totalRecords, Direction orderDirection, IQuery customFilter) { - throw new NotImplementedException(); + var customFilterWheres = customFilter != null ? customFilter.GetWhereClauses().ToArray() : null; + var hasCustomFilter = customFilterWheres != null && customFilterWheres.Length > 0; + + if (hasCustomFilter) + { + var filterSql = new Sql(); + foreach (var filterClause in customFilterWheres) + { + filterSql.Append(string.Format("AND ({0})", filterClause.Item1), filterClause.Item2); + } + } + + var sql = GetBaseQuery(false); + + if (orderDirection == Direction.Descending) + sql.OrderByDescending("Datestamp"); + else + sql.OrderBy("Datestamp"); + + if (query == null) query = new Query(); + var translatorIds = new SqlTranslator(sql, query); + var translatedQuery = translatorIds.Translate(); + + // Get page of results and total count + var pagedResult = Database.Page(pageIndex + 1, pageSize, translatedQuery); + totalRecords = pagedResult.TotalItems; + + return pagedResult.Items.Select( + dto => new AuditItem(dto.Id, dto.Comment, Enum.Parse(dto.Header), dto.UserId, dto.UserName, dto.UserAvatar)).ToArray(); } - #region Not Implemented - not needed - - protected override void PersistUpdatedItem(AuditItem entity) + protected override void PersistUpdatedItem(IAuditItem entity) { Database.Insert(new LogDto { @@ -35,26 +64,40 @@ namespace Umbraco.Core.Persistence.Repositories }); } - protected override AuditItem PerformGet(int id) - { - throw new NotImplementedException(); - } - - protected override IEnumerable PerformGetAll(params int[] ids) - { - throw new NotImplementedException(); - } - - protected override IEnumerable PerformGetByQuery(IQuery query) - { - throw new NotImplementedException(); - } - protected override Sql GetBaseQuery(bool isCount) + { + var sql = new Sql() + .Select(isCount ? "COUNT(*)" : "umbracoLog.id, umbracoLog.userId, umbracoLog.NodeId, umbracoLog.Datestamp, umbracoLog.logHeader, umbracoLog.logComment, umbracoUser.userName, umbracoUser.avatar as userAvatar") + .From(SqlSyntax); + if (isCount == false) + { + sql = sql.LeftJoin(SqlSyntax).On(SqlSyntax, dto => dto.Id, dto => dto.UserId); + } + return sql; + } + + #region Not Implemented - not needed currently + + protected override void PersistNewItem(IAuditItem entity) { throw new NotImplementedException(); } + protected override IAuditItem PerformGet(int id) + { + throw new NotImplementedException(); + } + + protected override IEnumerable PerformGetAll(params int[] ids) + { + throw new NotImplementedException(); + } + + protected override IEnumerable PerformGetByQuery(IQuery query) + { + throw new NotImplementedException(); + } + protected override string GetBaseWhereClause() { throw new NotImplementedException(); @@ -70,7 +113,6 @@ namespace Umbraco.Core.Persistence.Repositories get { throw new NotImplementedException(); } } #endregion - - + } } \ No newline at end of file diff --git a/src/Umbraco.Core/Persistence/Repositories/Interfaces/IAuditRepository.cs b/src/Umbraco.Core/Persistence/Repositories/Interfaces/IAuditRepository.cs index fe571a49da..ef9c7e5641 100644 --- a/src/Umbraco.Core/Persistence/Repositories/Interfaces/IAuditRepository.cs +++ b/src/Umbraco.Core/Persistence/Repositories/Interfaces/IAuditRepository.cs @@ -1,11 +1,17 @@ using System.Collections; +using System.Collections.Generic; using Umbraco.Core.Auditing; using Umbraco.Core.Models; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Umbraco.Core.Persistence.Querying; namespace Umbraco.Core.Persistence.Repositories { - public interface IAuditRepository : IRepository + public interface IAuditRepository : IRepository { - + IEnumerable GetPagedResultsByQuery( + IQuery query, + long pageIndex, int pageSize, out long totalRecords, + Direction orderDirection, IQuery customFilter); } } \ No newline at end of file diff --git a/src/Umbraco.Core/Services/AuditService.cs b/src/Umbraco.Core/Services/AuditService.cs index 642d89cb39..d224ffb7ea 100644 --- a/src/Umbraco.Core/Services/AuditService.cs +++ b/src/Umbraco.Core/Services/AuditService.cs @@ -1,8 +1,12 @@ using System; +using System.Collections.Generic; +using System.Linq; using Umbraco.Core.Events; using Umbraco.Core.Logging; using Umbraco.Core.Models; using Umbraco.Core.Persistence; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Umbraco.Core.Persistence.Querying; using Umbraco.Core.Persistence.UnitOfWork; namespace Umbraco.Core.Services @@ -22,5 +26,26 @@ namespace Umbraco.Core.Services uow.Commit(); } } + + public IEnumerable GetPagedItems(int id, long pageIndex, int pageSize, out long totalRecords, Direction orderDirection = Direction.Descending, IQuery filter = null) + { + Mandate.ParameterCondition(pageIndex >= 0, "pageIndex"); + Mandate.ParameterCondition(pageSize > 0, "pageSize"); + + if (id == Constants.System.Root || id <= 0) + { + totalRecords = 0; + return Enumerable.Empty(); + } + + using (var uow = UowProvider.GetUnitOfWork(readOnly: true)) + { + var repository = RepositoryFactory.CreateAuditRepository(uow); + + var query = Query.Builder.Where(x => x.Id == id); + + return repository.GetPagedResultsByQuery(query, pageIndex, pageSize, out totalRecords, orderDirection, filter); + } + } } } \ No newline at end of file diff --git a/src/Umbraco.Core/Services/IAuditService.cs b/src/Umbraco.Core/Services/IAuditService.cs index ce02a2ff90..0e5c114855 100644 --- a/src/Umbraco.Core/Services/IAuditService.cs +++ b/src/Umbraco.Core/Services/IAuditService.cs @@ -1,9 +1,29 @@ +using System.Collections.Generic; using Umbraco.Core.Models; +using Umbraco.Core.Persistence.DatabaseModelDefinitions; +using Umbraco.Core.Persistence.Querying; namespace Umbraco.Core.Services { public interface IAuditService : IService { void Add(AuditType type, string comment, int userId, int objectId); + + /// + /// Returns paged items in the audit trail + /// + /// + /// + /// + /// + /// + /// By default this will always be ordered descending (newest first) + /// + /// + /// Optional filter to be applied + /// + /// + IEnumerable GetPagedItems(int id, long pageIndex, int pageSize, out long totalRecords, + Direction orderDirection = Direction.Descending, IQuery filter = null); } } \ No newline at end of file diff --git a/src/Umbraco.Core/Umbraco.Core.csproj b/src/Umbraco.Core/Umbraco.Core.csproj index 7817b24ff1..be1366ab3c 100644 --- a/src/Umbraco.Core/Umbraco.Core.csproj +++ b/src/Umbraco.Core/Umbraco.Core.csproj @@ -359,6 +359,7 @@ + @@ -370,6 +371,7 @@ + @@ -530,6 +532,7 @@ + diff --git a/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs b/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs index 5e5730871c..f42bfc9d83 100644 --- a/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs +++ b/src/Umbraco.Web/Models/ContentEditing/AuditLog.cs @@ -1,24 +1,31 @@ using System; using System.Runtime.Serialization; +using Umbraco.Core.Models; namespace Umbraco.Web.Models.ContentEditing { [DataContract(Name = "auditLog", Namespace = "")] public class AuditLog { - [DataMember(Name = "userId", IsRequired = true)] + [DataMember(Name = "userId")] public int UserId { get; set; } - [DataMember(Name = "nodeId", IsRequired = true)] + [DataMember(Name = "userName")] + public string UserName { get; set; } + + [DataMember(Name = "userAvatar")] + public string UserAvatar { get; set; } + + [DataMember(Name = "nodeId")] public int NodeId { get; set; } - [DataMember(Name = "timestamp", IsRequired = true)] + [DataMember(Name = "timestamp")] public DateTime Timestamp { get; set; } - [DataMember(Name = "logType", IsRequired = true)] - public AuditLogType LogType { get; set; } + [DataMember(Name = "logType")] + public AuditType LogType { get; set; } - [DataMember(Name = "comment", IsRequired = true)] + [DataMember(Name = "comment")] public string Comment { get; set; } } } \ No newline at end of file diff --git a/src/Umbraco.Web/Models/ContentEditing/AuditLogType.cs b/src/Umbraco.Web/Models/ContentEditing/AuditLogType.cs index a023d5ba98..5907f10eee 100644 --- a/src/Umbraco.Web/Models/ContentEditing/AuditLogType.cs +++ b/src/Umbraco.Web/Models/ContentEditing/AuditLogType.cs @@ -1,13 +1,14 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Text; using System.Threading.Tasks; namespace Umbraco.Web.Models.ContentEditing { - /// - /// Defines audit trail log types - /// + [Obsolete("Use Umbraco.Core.Models.AuditType instead")] + [EditorBrowsable(EditorBrowsableState.Never)] public enum AuditLogType { /// diff --git a/src/Umbraco.Web/Models/Mapping/DashboardModelsMapper.cs b/src/Umbraco.Web/Models/Mapping/MiscModelsMapper.cs similarity index 60% rename from src/Umbraco.Web/Models/Mapping/DashboardModelsMapper.cs rename to src/Umbraco.Web/Models/Mapping/MiscModelsMapper.cs index 66510a2263..dac7648782 100644 --- a/src/Umbraco.Web/Models/Mapping/DashboardModelsMapper.cs +++ b/src/Umbraco.Web/Models/Mapping/MiscModelsMapper.cs @@ -13,7 +13,7 @@ namespace Umbraco.Web.Models.Mapping /// /// A model mapper used to map models for the various dashboards /// - internal class DashboardModelsMapper : MapperConfiguration + internal class MiscModelsMapper : MapperConfiguration { public override void ConfigureMappings(IConfiguration config, ApplicationContext applicationContext) { @@ -24,7 +24,14 @@ namespace Umbraco.Web.Models.Mapping //for the logging controller (and assuming dashboard that is used in uaas? otherwise not sure what that controller is used for) config.CreateMap() - .ForMember(log => log.LogType, expression => expression.MapFrom(item => Enum.Parse(item.LogType.ToString()))); + .ForMember(log => log.UserAvatar, expression => expression.Ignore()) + .ForMember(log => log.UserName, expression => expression.Ignore()) + .ForMember(log => log.LogType, expression => expression.MapFrom(item => Enum.Parse(item.LogType.ToString()))); + + config.CreateMap() + .ForMember(log => log.NodeId, expression => expression.MapFrom(item => item.Id)) + .ForMember(log => log.Timestamp, expression => expression.MapFrom(item => item.CreateDate)) + .ForMember(log => log.LogType, expression => expression.MapFrom(item => item.AuditType)); } } } \ No newline at end of file diff --git a/src/Umbraco.Web/Umbraco.Web.csproj b/src/Umbraco.Web/Umbraco.Web.csproj index a210c269d0..c4a7b5e201 100644 --- a/src/Umbraco.Web/Umbraco.Web.csproj +++ b/src/Umbraco.Web/Umbraco.Web.csproj @@ -647,7 +647,7 @@ - + diff --git a/src/umbraco.businesslogic/Log.cs b/src/umbraco.businesslogic/Log.cs index 9ce251675f..57bbbf3713 100644 --- a/src/umbraco.businesslogic/Log.cs +++ b/src/umbraco.businesslogic/Log.cs @@ -56,17 +56,11 @@ namespace umbraco.BusinessLogic public static Log Instance { get { return Singleton.Instance; } - } - + } + #endregion - - /// - /// Adds the specified log item to the log. - /// - /// The log type. - /// The user adding the item. - /// The affected node id. - /// Comment. + + [Obsolete("Use IAuditService.Add instead")] public static void Add(LogTypes type, User user, int nodeId, string comment) { if (Instance.ExternalLogger != null) @@ -118,13 +112,7 @@ namespace umbraco.BusinessLogic } } - /// - /// Adds the specified log item to the Umbraco log no matter if an external logger has been defined. - /// - /// The log type. - /// The user adding the item. - /// The affected node id. - /// Comment. + [Obsolete("Use IAuditService.Add instead")] public static void AddLocally(LogTypes type, User user, int nodeId, string comment) { if (comment.Length > 3999) @@ -140,24 +128,13 @@ namespace umbraco.BusinessLogic AddSynced(type, user == null ? 0 : user.Id, nodeId, comment); } - /// - /// Adds the specified log item to the log without any user information attached. - /// - /// The log type. - /// The affected node id. - /// Comment. + [Obsolete("Use IAuditService.Add instead")] public static void Add(LogTypes type, int nodeId, string comment) { Add(type, null, nodeId, comment); } - /// - /// Adds a log item to the log immidiately instead of Queuing it as a work item. - /// - /// The type. - /// The user id. - /// The node id. - /// The comment. + [Obsolete("Use IAuditService.Add instead")] public static void AddSynced(LogTypes type, int userId, int nodeId, string comment) { var logTypeIsAuditType = type.GetType().GetField(type.ToString()).GetCustomAttributes(typeof(AuditTrailLogItem), true).Length != 0; @@ -192,7 +169,8 @@ namespace umbraco.BusinessLogic "Redirected log call (please use Umbraco.Core.Logging.LogHelper instead of umbraco.BusinessLogic.Log) | Type: {0} | User: {1} | NodeId: {2} | Comment: {3}", () => type.ToString(), () => userId, () => nodeId.ToString(CultureInfo.InvariantCulture), () => comment); } - + + [Obsolete("Use IAuditService.GetPagedItems instead")] public List GetAuditLogItems(int NodeId) { if (UmbracoConfig.For.UmbracoSettings().Logging.ExternalLoggerEnableAuditTrail && ExternalLogger != null) @@ -204,6 +182,7 @@ namespace umbraco.BusinessLogic sqlHelper.CreateParameter("@id", NodeId))); } + [Obsolete("Use IAuditService.GetPagedItems instead")] public List GetLogItems(LogTypes type, DateTime sinceDate) { if (ExternalLogger != null) @@ -216,6 +195,7 @@ namespace umbraco.BusinessLogic sqlHelper.CreateParameter("@dateStamp", sinceDate))); } + [Obsolete("Use IAuditService.GetPagedItems instead")] public List GetLogItems(int nodeId) { if (ExternalLogger != null) @@ -227,6 +207,7 @@ namespace umbraco.BusinessLogic sqlHelper.CreateParameter("@id", nodeId))); } + [Obsolete("Use IAuditService.GetPagedItems instead")] public List GetLogItems(User user, DateTime sinceDate) { if (ExternalLogger != null) @@ -239,6 +220,7 @@ namespace umbraco.BusinessLogic sqlHelper.CreateParameter("@dateStamp", sinceDate))); } + [Obsolete("Use IAuditService.GetPagedItems instead")] public List GetLogItems(User user, LogTypes type, DateTime sinceDate) { if (ExternalLogger != null)