Browse Source

Temporary commit.

pull/306/head
Sebastian 8 years ago
parent
commit
753419d07e
  1. 4
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs
  2. 2
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs
  3. 24
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs
  4. 1
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  5. 7
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  6. 4
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  7. 3
      src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs
  8. 44
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  9. 35
      src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs
  10. 3
      src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs
  11. 4
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  12. 42
      src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs
  13. 7
      src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs
  14. 7
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  15. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs
  16. 62
      src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs
  17. 2
      src/Squidex.Infrastructure/ValidationException.cs
  18. 10
      src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs
  19. 3
      src/Squidex/Config/Domain/RuleServices.cs
  20. 2
      src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs
  21. 21
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html
  22. 26
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts
  23. 2
      src/Squidex/app/shared/state/rules.state.ts
  24. 43
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/ContentChangedTriggerTests.cs
  25. 77
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs
  26. 72
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs
  27. 67
      tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs

4
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs

@ -22,5 +22,9 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers
public bool SendPublish { get; set; } public bool SendPublish { get; set; }
public bool SendUnpublish { get; set; } public bool SendUnpublish { get; set; }
public bool SendArchived { get; set; }
public bool SendRestore { get; set; }
} }
} }

2
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs

@ -9,9 +9,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{ {
public enum EnrichedContentEventType public enum EnrichedContentEventType
{ {
Archived,
Created, Created,
Deleted, Deleted,
Published, Published,
Restored,
Unpublished, Unpublished,
Updated Updated
} }

24
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs

@ -17,7 +17,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers
{ {
protected override bool Triggers(Envelope<AppEvent> @event, ContentChangedTrigger trigger) protected override bool Triggers(Envelope<AppEvent> @event, ContentChangedTrigger trigger)
{ {
if (trigger.HandleAll && @event.Payload is ContentEvent) if (trigger.HandleAll &&
@event.Payload is ContentEvent &&
!(@event.Payload is ContentChangesPublished) &&
!(@event.Payload is ContentChangesDiscarded) &&
!(@event.Payload is ContentUpdateProposed))
{ {
return true; return true;
} }
@ -44,21 +48,33 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers
private static bool MatchsType(ContentChangedTriggerSchema schema, SchemaEvent @event) private static bool MatchsType(ContentChangedTriggerSchema schema, SchemaEvent @event)
{ {
return return
IsArchived(schema, @event) ||
IsCreate(schema, @event) || IsCreate(schema, @event) ||
IsUpdate(schema, @event) ||
IsDelete(schema, @event) || IsDelete(schema, @event) ||
IsPublished(schema, @event) || IsPublished(schema, @event) ||
IsRestored(schema, @event) ||
IsUpdate(schema, @event) ||
IsUnpublished(schema, @event); IsUnpublished(schema, @event);
} }
private static bool IsPublished(ContentChangedTriggerSchema schema, SchemaEvent @event) private static bool IsPublished(ContentChangedTriggerSchema schema, SchemaEvent @event)
{ {
return schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Status == Status.Published; return schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Published;
}
private static bool IsRestored(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendRestore && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Restored;
}
private static bool IsArchived(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendArchived && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Archived;
} }
private static bool IsUnpublished(ContentChangedTriggerSchema schema, SchemaEvent @event) private static bool IsUnpublished(ContentChangedTriggerSchema schema, SchemaEvent @event)
{ {
return schema.SendUnpublish && @event is ContentStatusChanged statusChanged && statusChanged.Status != Status.Published; return schema.SendUnpublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Unpublished;
} }
private static bool IsCreate(ContentChangedTriggerSchema schema, SchemaEvent @event) private static bool IsCreate(ContentChangedTriggerSchema schema, SchemaEvent @event)

1
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -31,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities
Guard.NotNull(localCache, nameof(localCache)); Guard.NotNull(localCache, nameof(localCache));
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
this.localCache = localCache; this.localCache = localCache;
} }

7
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -57,13 +57,6 @@ namespace Squidex.Domain.Apps.Entities.Apps
this.initialPatterns = initialPatterns; this.initialPatterns = initialPatterns;
} }
public override Task OnActivateAsync()
{
CleanupOldSnapshots();
return base.OnActivateAsync();
}
protected override Task<object> ExecuteAsync(IAggregateCommand command) protected override Task<object> ExecuteAsync(IAggregateCommand command)
{ {
VerifyNotArchived(); VerifyNotArchived();

4
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -140,9 +140,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
ApplySnapshot(Snapshot.Apply(@event)); ApplySnapshot(Snapshot.Apply(@event));
} }
public Task<J<IAssetEntity>> GetStateAsync(long version = EtagVersion.Empty) public Task<J<IAssetEntity>> GetStateAsync()
{ {
return J.AsTask<IAssetEntity>(GetSnapshot(version)); return J.AsTask<IAssetEntity>(Snapshot);
} }
} }
} }

3
src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
@ -14,6 +13,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
public interface IAssetGrain : IDomainObjectGrain public interface IAssetGrain : IDomainObjectGrain
{ {
Task<J<IAssetEntity>> GetStateAsync(long version = EtagVersion.Any); Task<J<IAssetEntity>> GetStateAsync();
} }
} }

44
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -117,9 +117,28 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
else else
{ {
await ctx.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data); var reason = StatusChange.Published;
ChangeStatus(c); if (c.Status == Status.Published)
{
reason = StatusChange.Published;
}
else if (c.Status == Status.Archived)
{
reason = StatusChange.Archived;
}
else if (Snapshot.Status == Status.Published)
{
reason = StatusChange.Unpublished;
}
else
{
reason = StatusChange.Restored;
}
await ctx.ExecuteScriptAsync(x => x.ScriptChange, reason, c, Snapshot.Data);
ChangeStatus(c, reason);
} }
} }
} }
@ -206,7 +225,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (command.Publish) if (command.Publish)
{ {
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published })); RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published }));
} }
} }
@ -245,9 +264,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value })); RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value }));
} }
public void ChangeStatus(ChangeContentStatus command) public void ChangeStatus(ChangeContentStatus command, StatusChange reason)
{ {
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged())); RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason }));
} }
private void RaiseEvent(SchemaEvent @event) private void RaiseEvent(SchemaEvent @event)
@ -281,19 +300,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
private async Task<ContentOperationContext> CreateContext(Guid appId, Guid schemaId, Func<string> message) private async Task<ContentOperationContext> CreateContext(Guid appId, Guid schemaId, Func<string> message)
{ {
var operationContext = var operationContext =
await ContentOperationContext.CreateAsync(appId, schemaId, await ContentOperationContext.CreateAsync(
appProvider, appId, schemaId,
assetRepository, appProvider, assetRepository, contentRepository, scriptEngine, message);
contentRepository,
scriptEngine,
message);
return operationContext; return operationContext;
} }
public Task<J<IContentEntity>> GetStateAsync(long version = -2) public Task<J<IContentEntity>> GetStateAsync()
{ {
return J.AsTask<IContentEntity>(GetSnapshot(version)); return J.AsTask<IContentEntity>(Snapshot);
} }
} }
} }

35
src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs

@ -7,38 +7,53 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.State;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentVersionLoader : IContentVersionLoader public sealed class ContentVersionLoader : IContentVersionLoader
{ {
private readonly IGrainFactory grainFactory; private readonly IStore<Guid> store;
private readonly FieldRegistry registry;
public ContentVersionLoader(IGrainFactory grainFactory) public ContentVersionLoader(IStore<Guid> store, FieldRegistry registry)
{ {
Guard.NotNull(grainFactory, nameof(grainFactory)); Guard.NotNull(store, nameof(store));
Guard.NotNull(registry, nameof(registry));
this.grainFactory = grainFactory; this.store = store;
this.registry = registry;
} }
public async Task<IContentEntity> LoadAsync(Guid id, long version) public async Task<IContentEntity> LoadAsync(Guid id, long version)
{ {
using (Profiler.TraceMethod<ContentVersionLoader>()) using (Profiler.TraceMethod<ContentVersionLoader>())
{ {
var grain = grainFactory.GetGrain<IContentGrain>(id); var content = new ContentState();
var persistence = store.WithEventSourcing<ContentGrain, Guid>(id, e =>
{
if (content.Version < version)
{
content = content.Apply(e);
content.Version++;
}
});
var content = await grain.GetStateAsync(version); await persistence.ReadAsync();
if (content.Value == null || content.Value.Version != version) if (content.Version != version)
{ {
throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity)); throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity));
} }
return content.Value; return content;
} }
} }
} }
} }

3
src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs

@ -6,7 +6,6 @@
// ========================================================================== // ==========================================================================
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
@ -14,6 +13,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public interface IContentGrain : IDomainObjectGrain public interface IContentGrain : IDomainObjectGrain
{ {
Task<J<IContentEntity>> GetStateAsync(long version = EtagVersion.Any); Task<J<IContentEntity>> GetStateAsync();
} }
} }

4
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -32,10 +32,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
public NamedContentData DataDraft { get; set; } public NamedContentData DataDraft { get; set; }
[JsonProperty] [JsonProperty]
public Status Status { get; set; } public ScheduleJob ScheduleJob { get; set; }
[JsonProperty] [JsonProperty]
public ScheduleJob ScheduleJob { get; set; } public Status Status { get; set; }
[JsonProperty] [JsonProperty]
public bool IsPending { get; set; } public bool IsPending { get; set; }

42
src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs

@ -8,7 +8,6 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using NodaTime;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
@ -31,19 +30,16 @@ namespace Squidex.Domain.Apps.Entities.Rules
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
private readonly IMemoryCache cache; private readonly IMemoryCache cache;
private readonly IUserResolver userResolver; private readonly IUserResolver userResolver;
private readonly IClock clock;
public EventEnricher(IGrainFactory grainFactory, IMemoryCache cache, IUserResolver userResolver, IClock clock) public EventEnricher(IGrainFactory grainFactory, IMemoryCache cache, IUserResolver userResolver)
{ {
Guard.NotNull(grainFactory, nameof(grainFactory)); Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(cache, nameof(cache)); Guard.NotNull(cache, nameof(cache));
Guard.NotNull(clock, nameof(clock));
Guard.NotNull(userResolver, nameof(userResolver)); Guard.NotNull(userResolver, nameof(userResolver));
this.userResolver = userResolver;
this.cache = cache;
this.clock = clock;
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
this.cache = cache;
this.userResolver = userResolver;
} }
public async Task<EnrichedEvent> EnrichAsync(Envelope<AppEvent> @event) public async Task<EnrichedEvent> EnrichAsync(Envelope<AppEvent> @event)
@ -80,9 +76,10 @@ namespace Squidex.Domain.Apps.Entities.Rules
var asset = var asset =
(await grainFactory (await grainFactory
.GetGrain<IAssetGrain>(assetEvent.AssetId) .GetGrain<IAssetGrain>(assetEvent.AssetId)
.GetStateAsync(@event.Headers.EventStreamNumber())).Value; .GetStateAsync()).Value;
SimpleMapper.Map(asset, result); SimpleMapper.Map(asset, result);
SimpleMapper.Map(assetEvent, result);
switch (assetEvent) switch (assetEvent)
{ {
@ -108,12 +105,14 @@ namespace Squidex.Domain.Apps.Entities.Rules
var content = var content =
(await grainFactory (await grainFactory
.GetGrain<IContentGrain>(contentEvent.ContentId) .GetGrain<IContentGrain>(contentEvent.ContentId)
.GetStateAsync(@event.Headers.EventStreamNumber())).Value; .GetStateAsync()).Value;
SimpleMapper.Map(content, result); SimpleMapper.Map(content, result);
result.Data = content.Data ?? content.DataDraft; result.Data = content.Data ?? content.DataDraft;
SimpleMapper.Map(contentEvent, result);
switch (contentEvent) switch (contentEvent)
{ {
case ContentCreated _: case ContentCreated _:
@ -122,17 +121,25 @@ namespace Squidex.Domain.Apps.Entities.Rules
case ContentDeleted _: case ContentDeleted _:
result.Type = EnrichedContentEventType.Deleted; result.Type = EnrichedContentEventType.Deleted;
break; break;
case ContentChangesPublished _:
case ContentUpdated _: case ContentUpdated _:
result.Type = EnrichedContentEventType.Updated; result.Type = EnrichedContentEventType.Updated;
break; break;
case ContentStatusChanged contentStatusChanged: case ContentStatusChanged contentStatusChanged:
if (contentStatusChanged.Status == Status.Published) switch (contentStatusChanged.Change)
{
result.Type = EnrichedContentEventType.Published;
}
else
{ {
result.Type = EnrichedContentEventType.Unpublished; case StatusChange.Published:
result.Type = EnrichedContentEventType.Published;
break;
case StatusChange.Unpublished:
result.Type = EnrichedContentEventType.Unpublished;
break;
case StatusChange.Archived:
result.Type = EnrichedContentEventType.Archived;
break;
case StatusChange.Restored:
result.Type = EnrichedContentEventType.Restored;
break;
} }
break; break;
@ -143,10 +150,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
private async Task EnrichDefaultAsync(EnrichedEvent result, Envelope<AppEvent> @event) private async Task EnrichDefaultAsync(EnrichedEvent result, Envelope<AppEvent> @event)
{ {
result.Timestamp = result.Timestamp = @event.Headers.Timestamp();
@event.Headers.Contains(CommonHeaders.Timestamp) ?
@event.Headers.Timestamp() :
clock.GetCurrentInstant();
if (@event.Payload is SquidexEvent squidexEvent) if (@event.Payload is SquidexEvent squidexEvent)
{ {

7
src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs

@ -34,13 +34,6 @@ namespace Squidex.Domain.Apps.Entities.Rules
this.appProvider = appProvider; this.appProvider = appProvider;
} }
public override Task OnActivateAsync()
{
CleanupOldSnapshots();
return base.OnActivateAsync();
}
protected override Task<object> ExecuteAsync(IAggregateCommand command) protected override Task<object> ExecuteAsync(IAggregateCommand command)
{ {
VerifyNotDeleted(); VerifyNotDeleted();

7
src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs

@ -40,13 +40,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas
this.registry = registry; this.registry = registry;
} }
public override Task OnActivateAsync()
{
CleanupOldSnapshots();
return base.OnActivateAsync();
}
protected override Task<object> ExecuteAsync(IAggregateCommand command) protected override Task<object> ExecuteAsync(IAggregateCommand command)
{ {
VerifyNotDeleted(); VerifyNotDeleted();

2
src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentStatusChanged))] [EventType(nameof(ContentStatusChanged))]
public sealed class ContentStatusChanged : ContentEvent public sealed class ContentStatusChanged : ContentEvent
{ {
public StatusChange? Change { get; set; }
public Status Status { get; set; } public Status Status { get; set; }
} }
} }

62
src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs

@ -21,9 +21,8 @@ namespace Squidex.Infrastructure.Commands
private readonly List<Envelope<IEvent>> uncomittedEvents = new List<Envelope<IEvent>>(); private readonly List<Envelope<IEvent>> uncomittedEvents = new List<Envelope<IEvent>>();
private readonly IStore<Guid> store; private readonly IStore<Guid> store;
private readonly ISemanticLog log; private readonly ISemanticLog log;
private readonly List<T> snapshots = new List<T> { new T { Version = EtagVersion.Empty } };
private bool cleanup;
private Guid id; private Guid id;
private T snapshot = new T { Version = EtagVersion.Empty };
private IPersistence<T> persistence; private IPersistence<T> persistence;
public Guid Id public Guid Id
@ -33,17 +32,17 @@ namespace Squidex.Infrastructure.Commands
public long Version public long Version
{ {
get { return snapshots.Count - 2; } get { return snapshot.Version; }
} }
public long NewVersion public long NewVersion
{ {
get { return Version; } get { return snapshot.Version + uncomittedEvents.Count; }
} }
public T Snapshot public T Snapshot
{ {
get { return snapshots[snapshots.Count - 1]; } get { return snapshot; }
} }
protected DomainObjectGrain(IStore<Guid> store, ISemanticLog log) protected DomainObjectGrain(IStore<Guid> store, ISemanticLog log)
@ -56,31 +55,6 @@ namespace Squidex.Infrastructure.Commands
this.log = log; this.log = log;
} }
public void CleanupOldSnapshots()
{
cleanup = true;
}
public T GetSnapshot(long version)
{
if (version == EtagVersion.Any)
{
return Snapshot;
}
if (version == EtagVersion.Empty)
{
return snapshots[0];
}
if (version >= 0 && version < snapshots.Count - 1)
{
return snapshots[(int)version + 1];
}
return default(T);
}
public override async Task OnActivateAsync(Guid key) public override async Task OnActivateAsync(Guid key)
{ {
using (log.MeasureInformation(w => w using (log.MeasureInformation(w => w
@ -122,11 +96,9 @@ namespace Squidex.Infrastructure.Commands
uncomittedEvents.Clear(); uncomittedEvents.Clear();
} }
public virtual void ApplySnapshot(T snapshot) public virtual void ApplySnapshot(T newSnapshot)
{ {
snapshot.Version = snapshots.Count - 1; snapshot = newSnapshot;
snapshots.Add(snapshot);
} }
public virtual void ApplyEvent(Envelope<IEvent> @event) public virtual void ApplyEvent(Envelope<IEvent> @event)
@ -200,8 +172,7 @@ namespace Squidex.Infrastructure.Commands
throw new DomainException("Object has already been created."); throw new DomainException("Object has already been created.");
} }
var size = snapshots.Count; var previousSnapshot = snapshot;
try try
{ {
var result = await handler(command); var result = await handler(command);
@ -210,8 +181,10 @@ namespace Squidex.Infrastructure.Commands
if (events.Length > 0) if (events.Length > 0)
{ {
snapshot.Version = NewVersion;
await persistence.WriteEventsAsync(events); await persistence.WriteEventsAsync(events);
await persistence.WriteSnapshotAsync(Snapshot); await persistence.WriteSnapshotAsync(snapshot);
} }
if (result == null) if (result == null)
@ -230,23 +203,12 @@ namespace Squidex.Infrastructure.Commands
} }
catch catch
{ {
while (snapshots.Count > size) snapshot = previousSnapshot;
{
snapshots.RemoveAt(snapshots.Count - 1);
}
throw; throw;
} }
finally finally
{ {
if (cleanup)
{
for (var i = 0; i < snapshots.Count - 1; i++)
{
snapshots[i] = default(T);
}
}
uncomittedEvents.Clear(); uncomittedEvents.Clear();
} }
} }
@ -260,4 +222,4 @@ namespace Squidex.Infrastructure.Commands
protected abstract Task<object> ExecuteAsync(IAggregateCommand command); protected abstract Task<object> ExecuteAsync(IAggregateCommand command);
} }
} }

2
src/Squidex.Infrastructure/ValidationException.cs

@ -21,7 +21,7 @@ namespace Squidex.Infrastructure
public IReadOnlyList<ValidationError> Errors public IReadOnlyList<ValidationError> Errors
{ {
get { return errors; } get { return errors ?? FallbackErrors; }
} }
public string Summary { get; } public string Summary { get; }

10
src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs

@ -40,5 +40,15 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
/// Determines whether to handle the event when a content is unpublished. /// Determines whether to handle the event when a content is unpublished.
/// </summary> /// </summary>
public bool SendUnpublish { get; set; } public bool SendUnpublish { get; set; }
/// <summary>
/// Determines whether to handle the event when a content is archived.
/// </summary>
public bool SendArchived { get; set; }
/// <summary>
/// Determines whether to handle the event when a content is restored.
/// </summary>
public bool SendRestore { get; set; }
} }
} }

3
src/Squidex/Config/Domain/RuleServices.cs

@ -18,6 +18,9 @@ namespace Squidex.Config.Domain
{ {
public static void AddMyRuleServices(this IServiceCollection services) public static void AddMyRuleServices(this IServiceCollection services)
{ {
services.AddSingletonAs<EventEnricher>()
.As<IEventEnricher>();
services.AddSingletonAs<AssetChangedTriggerHandler>() services.AddSingletonAs<AssetChangedTriggerHandler>()
.As<IRuleTriggerHandler>(); .As<IRuleTriggerHandler>();

2
src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs

@ -54,7 +54,7 @@ namespace Squidex.Pipeline
private static IActionResult OnValidationException(ValidationException ex) private static IActionResult OnValidationException(ValidationException ex)
{ {
return ErrorResult(400, new ErrorDto { Message = ex.Summary, Details = ex.Errors.Select(e => e.Message).ToArray() }); return ErrorResult(400, new ErrorDto { Message = ex.Summary, Details = ex.Errors?.Select(e => e.Message).ToArray() });
} }
private static IActionResult ErrorResult(int statusCode, ErrorDto error) private static IActionResult ErrorResult(int statusCode, ErrorDto error)

21
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html

@ -10,6 +10,9 @@
<col style="width: 40px" /> <col style="width: 40px" />
<col style="width: 40px" /> <col style="width: 40px" />
<col style="width: 40px" /> <col style="width: 40px" />
<col style="width: 40px" />
<col style="width: 40px" />
<col style="width: 40px" />
</colgroup> </colgroup>
<tr> <tr>
@ -31,6 +34,15 @@
<th class="text-center"> <th class="text-center">
<div class="rotated-label">Published</div> <div class="rotated-label">Published</div>
</th> </th>
<th class="text-center">
<div class="rotated-label">Unpublished</div>
</th>
<th class="text-center">
<div class="rotated-label">Archived</div>
</th>
<th class="text-center">
<div class="rotated-label">Restored</div>
</th>
<th></th> <th></th>
</tr> </tr>
@ -53,6 +65,15 @@
<td class="text-center" title="Published"> <td class="text-center" title="Published">
<input type="checkbox" [ngModel]="schema.sendPublish" (ngModelChange)="toggle(schema, 'sendPublish')" /> <input type="checkbox" [ngModel]="schema.sendPublish" (ngModelChange)="toggle(schema, 'sendPublish')" />
</td> </td>
<td class="text-center" title="Unpublished">
<input type="checkbox" [ngModel]="schema.sendUnpublish" (ngModelChange)="toggle(schema, 'sendUnpublish')" />
</td>
<td class="text-center" title="Archived">
<input type="checkbox" [ngModel]="schema.sendArchived" (ngModelChange)="toggle(schema, 'sendArchived')" />
</td>
<td class="text-center" title="Restored">
<input type="checkbox" [ngModel]="schema.sendRestored" (ngModelChange)="toggle(schema, 'sendRestored')" />
</td>
<td class="text-center"> <td class="text-center">
<button type="button" class="btn btn-link btn-secondary" (click)="removeSchema(schema)"> <button type="button" class="btn btn-link btn-secondary" (click)="removeSchema(schema)">
<i class="icon-close"></i> <i class="icon-close"></i>

26
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts

@ -21,6 +21,9 @@ export interface TriggerSchemaForm {
sendUpdate: boolean; sendUpdate: boolean;
sendDelete: boolean; sendDelete: boolean;
sendPublish: boolean; sendPublish: boolean;
sendUnpublish: boolean;
sendArchive: boolean;
sendRestore: boolean;
} }
@Component({ @Component({
@ -71,7 +74,10 @@ export class ContentChangedTriggerComponent implements OnInit {
sendCreate: triggerSchema.sendCreate, sendCreate: triggerSchema.sendCreate,
sendUpdate: triggerSchema.sendUpdate, sendUpdate: triggerSchema.sendUpdate,
sendDelete: triggerSchema.sendDelete, sendDelete: triggerSchema.sendDelete,
sendPublish: triggerSchema.sendPublish sendPublish: triggerSchema.sendPublish,
sendUnpublish: triggerSchema.sendUnpublish,
sendArchive: triggerSchema.sendArchive,
sendRestore: triggerSchema.sendRestore
}); });
} else { } else {
return null; return null;
@ -104,7 +110,10 @@ export class ContentChangedTriggerComponent implements OnInit {
sendCreate: false, sendCreate: false,
sendUpdate: false, sendUpdate: false,
sendDelete: false, sendDelete: false,
sendPublish: false sendPublish: false,
sendUnpublish: false,
sendArchive: false,
sendRestore: false
})).sortByStringAsc(x => x.schema.name); })).sortByStringAsc(x => x.schema.name);
this.updateValue(); this.updateValue();
@ -137,7 +146,10 @@ export class ContentChangedTriggerComponent implements OnInit {
sendCreate: s.sendCreate, sendCreate: s.sendCreate,
sendUpdate: s.sendUpdate, sendUpdate: s.sendUpdate,
sendDelete: s.sendDelete, sendDelete: s.sendDelete,
sendPublish: s.sendPublish sendPublish: s.sendPublish,
sendUnpublish: s.sendUnpublish,
sendArchive: s.sendArchive,
sendRestore: s.sendRestore
}; };
}); });
@ -150,6 +162,9 @@ export class ContentChangedTriggerComponent implements OnInit {
schemaForm.sendUpdate = value; schemaForm.sendUpdate = value;
schemaForm.sendDelete = value; schemaForm.sendDelete = value;
schemaForm.sendPublish = value; schemaForm.sendPublish = value;
schemaForm.sendUnpublish = value;
schemaForm.sendArchive = value;
schemaForm.sendRestore = value;
return schemaForm; return schemaForm;
} }
@ -159,7 +174,10 @@ export class ContentChangedTriggerComponent implements OnInit {
schemaForm.sendCreate && schemaForm.sendCreate &&
schemaForm.sendUpdate && schemaForm.sendUpdate &&
schemaForm.sendDelete && schemaForm.sendDelete &&
schemaForm.sendPublish; schemaForm.sendPublish &&
schemaForm.sendUnpublish &&
schemaForm.sendArchive &&
schemaForm.sendRestore;
return schemaForm; return schemaForm;
} }

2
src/Squidex/app/shared/state/rules.state.ts

@ -158,7 +158,7 @@ const updateTrigger = (rule: RuleDto, trigger: any, user: string, version: Versi
const updateAction = (rule: RuleDto, action: any, user: string, version: Version, now?: DateTime) => const updateAction = (rule: RuleDto, action: any, user: string, version: Version, now?: DateTime) =>
rule.with({ rule.with({
action, action,
actionType: action.triggerType, actionType: action.actionType,
lastModified: now || DateTime.now(), lastModified: now || DateTime.now(),
lastModifiedBy: user, lastModifiedBy: user,
version version

43
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/ContentChangedTriggerTests.cs

@ -32,19 +32,23 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
public static IEnumerable<object[]> TestData = new[] public static IEnumerable<object[]> TestData = new[]
{ {
new object[] { 0, 1, 1, 1, 1, new RuleCreated() }, new object[] { 0, 1, 1, 1, 1, 0, 0, 0, new RuleCreated() },
new object[] { 0, 1, 1, 1, 1, new ContentCreated { SchemaId = SchemaNonMatch } }, new object[] { 0, 1, 1, 1, 1, 0, 0, 0, new ContentCreated { SchemaId = SchemaNonMatch } },
new object[] { 1, 1, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } }, new object[] { 1, 1, 0, 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } }, new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 1, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } }, new object[] { 1, 0, 1, 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } }, new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 1, 0, new ContentDeleted { SchemaId = SchemaMatch } }, new object[] { 1, 0, 0, 1, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } }, new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 0, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } }, new object[] { 1, 1, 1, 1, 0, 0, 1, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Archived } },
new object[] { 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } }, new object[] { 0, 1, 1, 1, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Archived } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Archived } }, new object[] { 1, 0, 0, 0, 0, 0, 0, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Restored } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Draft } }, new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Restored } },
new object[] { 0, 1, 1, 1, 1, new SchemaCreated { SchemaId = SchemaNonMatch } } new object[] { 1, 0, 0, 0, 1, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Published } },
new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Published } },
new object[] { 1, 1, 1, 1, 0, 1, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Unpublished } },
new object[] { 0, 1, 1, 1, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Unpublished } },
new object[] { 0, 1, 1, 1, 1, 0, 0, 0, new SchemaCreated { SchemaId = SchemaNonMatch } }
}; };
[Fact] [Fact]
@ -69,7 +73,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
[Theory] [Theory]
[MemberData(nameof(TestData))] [MemberData(nameof(TestData))]
public void Should_return_result_depending_on_event(int expected, int sendCreate, int sendUpdate, int sendDelete, int sendPublish, AppEvent @event) public void Should_return_result_depending_on_event(int expected,
int sendCreate,
int sendUpdate,
int sendDelete,
int sendPublish,
int sendUnpublish,
int sendArchive,
int sendRestore,
AppEvent @event)
{ {
var trigger = new ContentChangedTrigger var trigger = new ContentChangedTrigger
{ {
@ -80,6 +92,9 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
SendUpdate = sendUpdate == 1, SendUpdate = sendUpdate == 1,
SendDelete = sendDelete == 1, SendDelete = sendDelete == 1,
SendPublish = sendPublish == 1, SendPublish = sendPublish == 1,
SendUnpublish = sendUnpublish == 1,
SendArchived = sendArchive == 1,
SendRestore = sendRestore == 1,
SchemaId = SchemaMatch.Id SchemaId = SchemaMatch.Id
}) })
}; };

77
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs

@ -144,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data }), CreateContentEvent(new ContentCreated { Data = data }),
CreateContentEvent(new ContentStatusChanged { Status = Status.Published }) CreateContentEvent(new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })
); );
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<create-script>")) A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<create-script>"))
@ -312,7 +312,75 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents LastEvents
.ShouldHaveSameEvents( .ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Published }) CreateContentEvent(new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
.MustHaveHappened();
}
[Fact]
public async Task ChangedStatus_should_create_events_and_update_state_when_archived()
{
var command = new ChangeContentStatus { Status = Status.Archived };
await ExecuteCreateAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(1));
Assert.Equal(Status.Archived, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Archived, Change = StatusChange.Archived })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
.MustHaveHappened();
}
[Fact]
public async Task ChangedStatus_should_create_events_and_update_state_when_unpublished()
{
var command = new ChangeContentStatus { Status = Status.Draft };
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(2));
Assert.Equal(Status.Draft, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Draft, Change = StatusChange.Unpublished })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
.MustHaveHappened();
}
[Fact]
public async Task ChangedStatus_should_create_events_and_update_state_when_restored()
{
var command = new ChangeContentStatus { Status = Status.Draft };
await ExecuteCreateAsync();
await ExecuteArchiveAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(2));
Assert.Equal(Status.Draft, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Draft, Change = StatusChange.Restored })
); );
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>")) A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
@ -459,6 +527,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return sut.ExecuteAsync(CreateContentCommand(new DeleteContent())); return sut.ExecuteAsync(CreateContentCommand(new DeleteContent()));
} }
private Task ExecuteArchiveAsync()
{
return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Archived }));
}
private Task ExecutePublishAsync() private Task ExecutePublishAsync()
{ {
return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Published })); return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Published }));

72
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs

@ -1,72 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class ContentVersionLoaderTests
{
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IContentGrain grain = A.Fake<IContentGrain>();
private readonly Guid id = Guid.NewGuid();
private readonly ContentVersionLoader sut;
public ContentVersionLoaderTests()
{
A.CallTo(() => grainFactory.GetGrain<IContentGrain>(id, null))
.Returns(grain);
sut = new ContentVersionLoader(grainFactory);
}
[Fact]
public async Task Should_throw_exception_if_no_state_returned()
{
A.CallTo(() => grain.GetStateAsync(10))
.Returns(new J<IContentEntity>(null));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10));
}
[Fact]
public async Task Should_throw_exception_if_state_has_other_version()
{
var entity = A.Fake<IContentEntity>();
A.CallTo(() => entity.Version)
.Returns(5);
A.CallTo(() => grain.GetStateAsync(10))
.Returns(J.Of(entity));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10));
}
[Fact]
public async Task Should_return_content_from_state()
{
var entity = A.Fake<IContentEntity>();
A.CallTo(() => entity.Version)
.Returns(10);
A.CallTo(() => grain.GetStateAsync(10))
.Returns(J.Of(entity));
var result = await sut.LoadAsync(id, 10);
Assert.Same(entity, result);
}
}
}

67
tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using FluentAssertions;
using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
@ -125,72 +124,6 @@ namespace Squidex.Infrastructure.Commands
Assert.Equal(EtagVersion.Empty, sut.Version); Assert.Equal(EtagVersion.Empty, sut.Version);
} }
[Fact]
public async Task Should_get_latestet_version_when_requesting_state_with_any()
{
await SetupEmptyAsync();
await sut.ExecuteAsync(C(new CreateAuto { Value = 5 }));
await sut.ExecuteAsync(C(new UpdateAuto { Value = 10 }));
var result = sut.GetSnapshot(EtagVersion.Any);
result.Should().BeEquivalentTo(new MyDomainState { Value = 10, Version = 1 });
}
[Fact]
public async Task Should_get_empty_version_when_requesting_state_with_empty_version()
{
await SetupEmptyAsync();
await sut.ExecuteAsync(C(new CreateAuto { Value = 5 }));
await sut.ExecuteAsync(C(new UpdateAuto { Value = 10 }));
var result = sut.GetSnapshot(EtagVersion.Empty);
result.Should().BeEquivalentTo(new MyDomainState { Value = 0, Version = -1 });
}
[Fact]
public async Task Should_get_specific_version_when_requesting_state_with_specific_version()
{
await SetupEmptyAsync();
await sut.ExecuteAsync(C(new CreateAuto { Value = 5 }));
await sut.ExecuteAsync(C(new UpdateAuto { Value = 10 }));
sut.GetSnapshot(0).Should().BeEquivalentTo(new MyDomainState { Value = 5, Version = 0 });
sut.GetSnapshot(1).Should().BeEquivalentTo(new MyDomainState { Value = 10, Version = 1 });
}
[Fact]
public async Task Should_automatically_cleanup_old_snapshots_when_enabled()
{
await SetupEmptyAsync();
sut.CleanupOldSnapshots();
await sut.ExecuteAsync(C(new CreateAuto { Value = 5 }));
await sut.ExecuteAsync(C(new UpdateAuto { Value = 10 }));
await sut.ExecuteAsync(C(new UpdateAuto { Value = 15 }));
Assert.Null(sut.GetSnapshot(0));
Assert.Null(sut.GetSnapshot(1));
Assert.NotNull(sut.GetSnapshot(2));
}
[Fact]
public async Task Should_get_null_state_when_requesting_state_with_invalid_version()
{
await SetupEmptyAsync();
await sut.ExecuteAsync(C(new CreateAuto { Value = 5 }));
await sut.ExecuteAsync(C(new UpdateAuto { Value = 10 }));
Assert.Null(sut.GetSnapshot(-3));
Assert.Null(sut.GetSnapshot(2));
}
[Fact] [Fact]
public async Task Should_write_state_and_events_when_created() public async Task Should_write_state_and_events_when_created()
{ {

Loading…
Cancel
Save