diff --git a/Samples/DotNetSdk/ObservationReportExporter/Context.cs b/Samples/DotNetSdk/ObservationReportExporter/Context.cs index 36de07b..1b7e8ef 100644 --- a/Samples/DotNetSdk/ObservationReportExporter/Context.cs +++ b/Samples/DotNetSdk/ObservationReportExporter/Context.cs @@ -11,7 +11,7 @@ public class Context public string TimeSeriesUsername { get; set; } public string TimeSeriesPassword { get; set; } public string ExportTemplateName { get; set; } - public string AttachmentFilename { get; set; } = "Report.xlsx"; + public string AttachmentFilename { get; set; } = FilenameGenerator.DefaultAttachmentFilename; public bool DryRun { get; set; } public bool DeleteExistingAttachments { get; set; } = true; @@ -21,6 +21,7 @@ public class Context public List LocationGroupIds { get; } = new List(); public List AnalyticalGroupIds { get; } = new List(); public List ObservedPropertyIds { get; } = new List(); + public DateTimeOffset? ExportTime { get; set; } public DateTimeOffset? StartTime { get; set; } public DateTimeOffset? EndTime { get; set; } } diff --git a/Samples/DotNetSdk/ObservationReportExporter/Exporter.cs b/Samples/DotNetSdk/ObservationReportExporter/Exporter.cs index 269b5b8..8ea5093 100644 --- a/Samples/DotNetSdk/ObservationReportExporter/Exporter.cs +++ b/Samples/DotNetSdk/ObservationReportExporter/Exporter.cs @@ -1,16 +1,26 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; +using System.Net; using System.Reflection; using System.Text; +using System.Text.RegularExpressions; using Aquarius.Samples.Client; using Aquarius.Samples.Client.ServiceModel; using Aquarius.TimeSeries.Client; +using Aquarius.TimeSeries.Client.ServiceModels.Acquisition; using Aquarius.TimeSeries.Client.ServiceModels.Provisioning; +using Aquarius.TimeSeries.Client.ServiceModels.Publish; using Humanizer; using log4net; using NodaTime; +using ObservationReportExporter.ExtraApis.Samples; +using ObservationReportExporter.ExtraApis.TimeSeries; using ServiceStack; +using ApplyTagRequest = Aquarius.TimeSeries.Client.ServiceModels.Acquisition.ApplyTagRequest; +using Attachment = Aquarius.TimeSeries.Client.ServiceModels.Publish.Attachment; using GetTags = Aquarius.TimeSeries.Client.ServiceModels.Provisioning.GetTags; namespace ObservationReportExporter @@ -24,16 +34,39 @@ public class Exporter private ISamplesClient Samples { get; set; } private IAquariusClient TimeSeries { get; set; } + private IServiceClient SiteVisit { get; set; } + private DateTimeOffset ExportTime { get; set; } + private List AppliedTags { get; } = new List(); + + private int ExportedLocations { get; set; } + private int SkippedLocations { get; set; } + private int Errors { get; set; } + private int DeletedAttachments { get; set; } public void Run() { + ExportTime = Context.ExportTime ?? DateTimeOffset.Now; + + var stopwatch = Stopwatch.StartNew(); + ValidateBeforeConnection(); using (Samples = CreateConnectedSamplesClient()) using (TimeSeries = CreateConnectedTimeSeriesClient()) { ValidateOnceConnected(); + + ExportAllLocations(); } + + LogAction($"Exported observations to {"location".ToQuantity(ExportedLocations)}, skipping {"unknown location".ToQuantity(SkippedLocations)}, deleting {"existing attachment".ToQuantity(DeletedAttachments)}, with {"detected error".ToQuantity(Errors)} in {stopwatch.Elapsed.Humanize()}."); + } + + private void LogAction(string message) + { + var prefix = Context.DryRun ? "DRY-RUN: " : string.Empty; + + Log.Info($"{prefix}{message}"); } private void ValidateBeforeConnection() @@ -44,6 +77,7 @@ private void ValidateBeforeConnection() ThrowIfMissing(nameof(Context.TimeSeriesUsername), Context.TimeSeriesUsername); ThrowIfMissing(nameof(Context.TimeSeriesPassword), Context.TimeSeriesPassword); ThrowIfMissing(nameof(Context.ExportTemplateName), Context.ExportTemplateName); + ThrowIfMissing(nameof(Context.AttachmentFilename), Context.AttachmentFilename); if (Context.EndTime < Context.StartTime) throw new ExpectedException($"/{nameof(Context.StartTime)} must be less than /{nameof(Context.EndTime)}"); @@ -80,6 +114,8 @@ private IAquariusClient CreateConnectedTimeSeriesClient() var client = AquariusClient.CreateConnectedClient(Context.TimeSeriesServer, Context.TimeSeriesUsername, Context.TimeSeriesPassword); + SiteVisit = client.RegisterCustomClient(Aquarius.TimeSeries.Client.EndPoints.Root.EndPoint + "/apps/v1"); + Log.Info($"Connected to {Context.TimeSeriesServer} ({client.ServerVersion}) as {Context.TimeSeriesUsername}"); return client; @@ -93,53 +129,31 @@ private void ValidateOnceConnected() private SpreadsheetTemplate ExportTemplate { get; set; } - private Dictionary TimeSeriesLocationAliases { get; set; } = + private Dictionary TimeSeriesLocationAliases { get; } = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + private List AnalyticalGroupIds { get; set; } + private List ObservedPropertyIds { get; set; } + private List SamplingLocationIds { get; set; } + private List SamplingLocationGroupIds { get; set; } + private void ValidateSamplesConfiguration() { - ExportTemplate = Samples - .Get(new GetSpreadsheetTemplates()) - .DomainObjects - .FirstOrDefault(t => t.CustomId.Equals(Context.ExportTemplateName, StringComparison.InvariantCultureIgnoreCase) && t.Type == SpreadsheetTemplateType.OBSERVATION_EXPORT); + LoadExportTemplate(); - if (ExportTemplate == null) - throw new ExpectedException($"'{Context.ExportTemplateName}' is not a known Observation Export spreadsheet template"); - - var exchangeConfiguration = Samples - .Get(new GetExchangeConfigurations()) - .DomainObjects - .FirstOrDefault(e => e.Type == "AQUARIUS_TIMESERIES"); - - if (exchangeConfiguration != null) - { - TimeSeriesLocationAliases.Clear(); - - foreach (var mapping in exchangeConfiguration.SamplingLocationMappings) - { - if (mapping.SamplingLocation.CustomId.Equals(mapping.ExternalLocation, StringComparison.InvariantCultureIgnoreCase)) - break; - - TimeSeriesLocationAliases.Add(mapping.SamplingLocation.CustomId, mapping.ExternalLocation); - } - } + LoadExchangeConfiguration(); var clauses = new List(); var builder = new StringBuilder(); - var startObservedTime = (Instant?)null; - var endObservedTime = (Instant?)null; - if (Context.StartTime.HasValue) { clauses.Add($"after {Context.StartTime:O}"); - startObservedTime = Instant.FromDateTimeOffset(Context.StartTime.Value); } if (Context.EndTime.HasValue) { clauses.Add($"before {Context.EndTime:O}"); - endObservedTime = Instant.FromDateTimeOffset(Context.EndTime.Value); } if (clauses.Any()) @@ -148,11 +162,6 @@ private void ValidateSamplesConfiguration() clauses.Clear(); } - var analyticalGroupIds = new List(); - var observedPropertyIds = new List(); - var samplingLocationIds = new List(); - var samplingLocationGroupIds = new List(); - var locationClauses = new List(); var locationGroupClauses = new List(); var analyticalGroupClauses = new List(); @@ -162,7 +171,7 @@ private void ValidateSamplesConfiguration() { Log.Info($"Resolving {"sampling location ID".ToQuantity(Context.LocationIds.Count)} ..."); - samplingLocationIds = + SamplingLocationIds = GetSpecificPaginatedItemIds( "location", locationClauses, @@ -176,7 +185,7 @@ private void ValidateSamplesConfiguration() { Log.Info($"Resolving {"sampling location group ID".ToQuantity(Context.LocationGroupIds.Count)} ..."); - samplingLocationGroupIds = + SamplingLocationGroupIds = GetItemIds( "location group", locationGroupClauses, @@ -189,7 +198,7 @@ private void ValidateSamplesConfiguration() { Log.Info($"Resolving {"analytical group ID".ToQuantity(Context.AnalyticalGroupIds.Count)} ..."); - analyticalGroupIds = + AnalyticalGroupIds = GetItemIds( "analytical group", analyticalGroupClauses, @@ -202,7 +211,7 @@ private void ValidateSamplesConfiguration() { Log.Info($"Resolving {"observed property ID".ToQuantity(Context.ObservedPropertyIds.Count)} ..."); - observedPropertyIds = + ObservedPropertyIds = GetItemIds( "observed property", observedPropertyClauses, @@ -221,33 +230,58 @@ private void ValidateSamplesConfiguration() if (builder.Length > 0) builder.Append(' '); - builder.Append($"with {string.Join(" and ", clauses)}"); + builder.Append($"from {string.Join(" and ", clauses)}"); } var summary = builder.ToString(); - Log.Info($"Exporting observations for {summary}."); + Log.Info($"Exporting observations {summary}."); } - private List GetSpecificPaginatedItemIds(string type, List clauses, List names, Func nameSelector, Func idSelector, Func requestFunc) - where TRequest : IPaginatedRequest, IReturn, new() - where TResponse : IPaginatedResponse + private void LoadExportTemplate() { - var items = names - .SelectMany(name => Samples.LazyGet(requestFunc(name)).DomainObjects) - .ToList(); + ExportTemplate = Samples + .Get(new GetSpreadsheetTemplates()) + .DomainObjects + .FirstOrDefault(t => + t.CustomId.Equals(Context.ExportTemplateName, StringComparison.InvariantCultureIgnoreCase) && + t.Type == SpreadsheetTemplateType.OBSERVATION_EXPORT); - return MapNamesToIds(type, clauses, names, items, nameSelector, idSelector); + if (ExportTemplate == null) + throw new ExpectedException($"'{Context.ExportTemplateName}' is not a known Observation Export spreadsheet template"); + + if (ExportTemplate.Attachments.Count != 1) + throw new ExpectedException($"Export template '{ExportTemplate.CustomId}' should have a single attachment, but {ExportTemplate.Attachments.Count} were found."); } + private void LoadExchangeConfiguration() + { + var exchangeConfiguration = Samples + .Get(new GetExchangeConfigurations()) + .DomainObjects + .FirstOrDefault(e => e.Type == "AQUARIUS_TIMESERIES"); + + if (exchangeConfiguration == null) + return; + + TimeSeriesLocationAliases.Clear(); + + foreach (var mapping in exchangeConfiguration.SamplingLocationMappings) + { + if (mapping.SamplingLocation.CustomId.Equals(mapping.ExternalLocation, StringComparison.InvariantCultureIgnoreCase)) + break; + + TimeSeriesLocationAliases.Add(mapping.SamplingLocation.CustomId, mapping.ExternalLocation); + } + } - private List GetPaginatedItemIds(string type, List clauses, List names, Func nameSelector, Func idSelector) + private List GetSpecificPaginatedItemIds(string type, List clauses, List names, Func nameSelector, Func idSelector, Func requestFunc) where TRequest : IPaginatedRequest, IReturn, new() where TResponse : IPaginatedResponse { - var response = Samples.LazyGet(new TRequest()); - - var items = response.DomainObjects.ToList(); + var items = names + .SelectMany(name => Samples.LazyGet(requestFunc(name)).DomainObjects) + .ToList(); return MapNamesToIds(type, clauses, names, items, nameSelector, idSelector); } @@ -287,26 +321,193 @@ private List MapNamesToIds(string type, List clau } - private Dictionary LocationTags { get; set; } = new Dictionary(); - private void ValidateTimeSeriesConfiguration() { + GetAttachmentFilename("DummyLocation"); + if (!Context.AttachmentTags.Any()) return; - LocationTags = TimeSeries + var locationTags = TimeSeries .Provisioning .Get(new GetTags()) .Results - .Where(t => t.AppliesToLocations) + .Where(t => t.AppliesToAttachments) .ToDictionary(t => t.Key, t => t, StringComparer.InvariantCultureIgnoreCase); - foreach (var key in Context.AttachmentTags.Keys) + AppliedTags.Clear(); + + foreach (var kvp in Context.AttachmentTags) { - if (!LocationTags.TryGetValue(key, out _)) - throw new ExpectedException($"'{key}' is not an existing tag with {nameof(Tag.AppliesToLocations)}=true"); + if (!locationTags.TryGetValue(kvp.Key, out var locationTag)) + throw new ExpectedException($"'{kvp.Key}' is not an existing tag with {nameof(Tag.AppliesToAttachments)}=true"); + + AppliedTags.Add(new ApplyTagRequest + { + UniqueId = locationTag.UniqueId, + Value = kvp.Value + }); } } + private void ExportAllLocations() + { + var exportedLocations = GetExportedLocations() + .OrderBy(l => l.CustomId) + .ToList(); + + Log.Info($"Exporting observations using the {ExportTemplate.CustomId} template for {"location".ToQuantity(exportedLocations.Count)} ..."); + + foreach (var exportedLocation in exportedLocations) + { + try + { + ExportLocation(exportedLocation); + } + catch (Exception e) + { + ++Errors; + Log.Warn($"Skipping export of '{exportedLocation.CustomId}': {e.Message}"); + } + } + } + + private IEnumerable GetExportedLocations() + { + if (SamplingLocationIds?.Any() ?? false) + return SamplingLocationIds + .Select(id => Samples.Get(new GetSamplingLocation { Id = id })); + + return Samples + .LazyGet(new GetSamplingLocations + { + SamplingLocationGroupIds = SamplingLocationGroupIds + }) + .DomainObjects; + } + + private void ExportLocation(SamplingLocation location) + { + if (!TimeSeriesLocationAliases.TryGetValue(location.CustomId, out var aqtsLocationIdentifier)) + aqtsLocationIdentifier = location.CustomId; + + var locationDescriptions = TimeSeries.Publish.Get(new LocationDescriptionListServiceRequest + { + LocationIdentifier = aqtsLocationIdentifier + }) + .LocationDescriptions; + + if (!locationDescriptions.Any()) + { + Log.Warn($"AQTS Location '{aqtsLocationIdentifier}' does not exist. Skipping this location's export."); + ++SkippedLocations; + return; + } + + if (locationDescriptions.Count != 1) + throw new ExpectedException( + $"'{aqtsLocationIdentifier}' is an ambiguous AQTS location identifier for {locationDescriptions.Count} locations: '{string.Join("', '", locationDescriptions.Select(l => l.Identifier))}'"); + + var locationDescription = locationDescriptions.Single(); + + var locationData = TimeSeries.Publish.Get(new LocationDataServiceRequest + { + LocationIdentifier = locationDescription.Identifier, + IncludeLocationAttachments = true + }); + + var attachmentFilename = GetAttachmentFilename(locationDescription.Identifier); + + var existingAttachments = locationData + .Attachments + .Where(a => a.FileName.Equals(attachmentFilename, StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + foreach (var existingAttachment in existingAttachments) + { + DeleteExistingAttachment(locationData, existingAttachment); + } + + LogAction($"Exporting observations from '{location.CustomId}' ..."); + + ++ExportedLocations; + + var exportRequest = new GetExportObservations + { + EndObservedTime = FromDateTimeOffset(Context.EndTime), + StartObservedTime = FromDateTimeOffset(Context.StartTime), + SamplingLocationIds = new List { location.Id }, + ObservedPropertyIds = ObservedPropertyIds, + AnalyticalGroupIds = AnalyticalGroupIds, + }; + + var url = $"{(Samples.Client as JsonServiceClient)?.BaseUri}{exportRequest.ToGetUrl()}&observationTemplateAttachmentId={ExportTemplate.Attachments.Single().Id}&format=xlsx"; + + if (Context.DryRun) + return; + + var contentBytes = ExportObservationsFromTemplate(url); + + Log.Info($"Uploading '{attachmentFilename}' ({contentBytes.Length.Bytes().Humanize("#.#")}) to '{locationData.Identifier}' ..."); + + UploadLocationAttachment(locationData.UniqueId, contentBytes, attachmentFilename); + } + + private static Instant? FromDateTimeOffset(DateTimeOffset? dateTimeOffset) + { + return dateTimeOffset.HasValue + ? (Instant?)Instant.FromDateTimeOffset(dateTimeOffset.Value) + : null; + } + + private byte[] ExportObservationsFromTemplate(string url) + { + using (var httpResponse = Samples.Client.Get(url)) + { + return httpResponse.GetResponseStream().ReadFully(); + } + } + + private void UploadLocationAttachment(Guid locationUniqueId, byte[] contentBytes, string filename) + { + using (var stream = new MemoryStream(contentBytes)) + { + TimeSeries.Acquisition.PostFileWithRequest(stream, filename, new PostLocationAttachment + { + LocationUniqueId = locationUniqueId, + Comments = $"Generated by {ExeHelper.ExeNameAndVersion}", + Tags = AppliedTags + }); + } + } + + private void DeleteExistingAttachment(LocationDataServiceResponse locationData, Attachment existingAttachment) + { + if (!Context.DeleteExistingAttachments) + return; + + var match = AttachmentUrlRegex.Match(existingAttachment.Url); + + if (!match.Success) + throw new ExpectedException($"Can't decode attachment ID from '{existingAttachment.Url}'"); + + var attachmentId = match.Groups["attachmentId"].Value; + + ++DeletedAttachments; + + LogAction($"Deleting existing attachment '{existingAttachment.FileName}' (uploaded {existingAttachment.DateUploaded:O} from '{locationData.Identifier}' ..."); + + if (Context.DryRun) + return; + + SiteVisit.Delete(new DeleteAttachmentById { Id = attachmentId }); + } + + private static readonly Regex AttachmentUrlRegex = new Regex(@"apps/v1/attachments/(?[^/]+)/download"); + + private string GetAttachmentFilename(string locationIdentifier) + { + return FilenameGenerator.GenerateAttachmentFilename(Context.AttachmentFilename, ExportTemplate.CustomId, locationIdentifier, ExportTime); + } } } diff --git a/Samples/DotNetSdk/ObservationReportExporter/GetExchangeConfigurations.cs b/Samples/DotNetSdk/ObservationReportExporter/ExtraApis/Samples/GetExchangeConfigurations.cs similarity index 95% rename from Samples/DotNetSdk/ObservationReportExporter/GetExchangeConfigurations.cs rename to Samples/DotNetSdk/ObservationReportExporter/ExtraApis/Samples/GetExchangeConfigurations.cs index 359bb9d..3632167 100644 --- a/Samples/DotNetSdk/ObservationReportExporter/GetExchangeConfigurations.cs +++ b/Samples/DotNetSdk/ObservationReportExporter/ExtraApis/Samples/GetExchangeConfigurations.cs @@ -2,7 +2,7 @@ using Aquarius.Samples.Client.ServiceModel; using ServiceStack; -namespace ObservationReportExporter +namespace ObservationReportExporter.ExtraApis.Samples { [Route("/v1/exchangeconfigurations", HttpMethods.Get)] public class GetExchangeConfigurations : IReturn diff --git a/Samples/DotNetSdk/ObservationReportExporter/ExtraApis/TimeSeries/DeleteAttachmentById.cs b/Samples/DotNetSdk/ObservationReportExporter/ExtraApis/TimeSeries/DeleteAttachmentById.cs new file mode 100644 index 0000000..8f1de79 --- /dev/null +++ b/Samples/DotNetSdk/ObservationReportExporter/ExtraApis/TimeSeries/DeleteAttachmentById.cs @@ -0,0 +1,10 @@ +using ServiceStack; + +namespace ObservationReportExporter.ExtraApis.TimeSeries +{ + [Route("/attachments/{Id}", HttpMethods.Delete)] + public class DeleteAttachmentById : IReturnVoid + { + public string Id { get; set; } + } +} diff --git a/Samples/DotNetSdk/ObservationReportExporter/FilenameGenerator.cs b/Samples/DotNetSdk/ObservationReportExporter/FilenameGenerator.cs new file mode 100644 index 0000000..0e2e372 --- /dev/null +++ b/Samples/DotNetSdk/ObservationReportExporter/FilenameGenerator.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +namespace ObservationReportExporter +{ + public class FilenameGenerator + { + public const string TemplatePattern = "Template"; + public const string LocationPattern = "Location"; + public const string TimePattern = "Time"; + + public const string DefaultAttachmentFilename = "{" + TemplatePattern + "}-{" + LocationPattern + "}.xlsx"; + + public static string GenerateAttachmentFilename(string attachmentFilename, string exportTemplateName, string locationIdentifier, DateTimeOffset exportTime) + { + var patterns = new Dictionary>(StringComparer.InvariantCultureIgnoreCase) + { + { TemplatePattern, _ => exportTemplateName }, + { LocationPattern, _ => locationIdentifier }, + { TimePattern, format => exportTime.ToString(string.IsNullOrWhiteSpace(format) ? "yyyy-MM-dd" : format) } + }; + + return PatternRegex.Replace(attachmentFilename, match => + { + var pattern = match.Groups["pattern"].Value.Trim(); + var format = match.Groups["format"].Value.Trim(); + + if (!patterns.TryGetValue(pattern, out var replacement)) + throw new ExpectedException($"'{pattern}' is not a known attachment filename substitution pattern. Try one of {{{string.Join("}, {", patterns.Keys)}}}."); + + return replacement(format); + }); + } + + private static readonly Regex PatternRegex = new Regex(@"\{(?[^:}]+)(:(?[^}]+))?\}"); + } +} diff --git a/Samples/DotNetSdk/ObservationReportExporter/ObservationReportExporter.csproj b/Samples/DotNetSdk/ObservationReportExporter/ObservationReportExporter.csproj index 9d2c523..2d6f47b 100644 --- a/Samples/DotNetSdk/ObservationReportExporter/ObservationReportExporter.csproj +++ b/Samples/DotNetSdk/ObservationReportExporter/ObservationReportExporter.csproj @@ -238,11 +238,13 @@ + + - + @@ -256,6 +258,7 @@ + diff --git a/Samples/DotNetSdk/ObservationReportExporter/Program.cs b/Samples/DotNetSdk/ObservationReportExporter/Program.cs index 6a23b8e..a4e9022 100644 --- a/Samples/DotNetSdk/ObservationReportExporter/Program.cs +++ b/Samples/DotNetSdk/ObservationReportExporter/Program.cs @@ -136,6 +136,13 @@ private static Context ParseArgs(string[] args) new Option(), new Option { Description = "Export options:" }, new Option + { + Key = nameof(context.DryRun), + Setter = value => context.DryRun = ParseBoolean(value), + Getter = () => $"{context.DryRun}", + Description = "When true, don't export and upload reports, just validate what would be done." + }, + new Option { Key = nameof(context.ExportTemplateName), Setter = value => context.ExportTemplateName = value, @@ -147,7 +154,7 @@ private static Context ParseArgs(string[] args) Key = nameof(context.AttachmentFilename), Setter = value => context.AttachmentFilename = value, Getter = () => context.AttachmentFilename, - Description = "Name of the generated report. Existing attachments with the same name will be deleted." + Description = $"Filename of the exported attachment." }, new Option { @@ -165,12 +172,13 @@ private static Context ParseArgs(string[] args) }, new Option { - Key = nameof(context.DryRun), - Setter = value => context.DryRun = ParseBoolean(value), - Getter = () => $"{context.DryRun}", - Description = "When true, don't export and upload reports, just validate what would be done." + Key = nameof(context.ExportTime), + Setter = value => context.ExportTime = ParseDateTimeOffset(value), + Getter = () => string.Empty, + Description = $"The timestamp used for all {{{FilenameGenerator.TimePattern}}} pattern substitutions. [default: The current time]" }, + new Option(), new Option { @@ -181,14 +189,14 @@ private static Context ParseArgs(string[] args) Key = nameof(context.StartTime), Setter = value => context.StartTime = ParseDateTimeOffset(value), Getter = () => string.Empty, - Description = "Include observations after this time." + Description = "Include observations after this time. [default: Start of record]" }, new Option { Key = nameof(context.EndTime), Setter = value => context.EndTime = ParseDateTimeOffset(value), Getter = () => string.Empty, - Description = "Include observations before this time." + Description = "Include observations before this time. [default: End of record]" }, new Option { @@ -221,7 +229,7 @@ private static Context ParseArgs(string[] args) }; var usageMessage - = $"Export observations from AQUARIUS Samples using a spreadsheet template and into AQUARIUS Time-Series." + = $"Export observations from AQUARIUS Samples using a spreadsheet template into AQUARIUS Time-Series locations as attachments." + $"\n" + $"\nusage: {ExeHelper.ExeName} [-option=value] [@optionsFile] ..." + $"\n" diff --git a/Samples/DotNetSdk/ObservationReportExporter/Readme.md b/Samples/DotNetSdk/ObservationReportExporter/Readme.md index c73f89a..81327e2 100644 --- a/Samples/DotNetSdk/ObservationReportExporter/Readme.md +++ b/Samples/DotNetSdk/ObservationReportExporter/Readme.md @@ -8,8 +8,8 @@ The intent is to run this tool on a schedule so that the uploaded location attac ## Features -- With no filters specified, all observed values will be exported. -- Results can be filtered by optional date range, location, project, property, or analytical group. +- A `/DryRun=true` can be used to quickly validate all the configured options, without actually exporting the observations and uploading the exported file to AQUARIUS Time-Series. +- Results can be filtered by optional date range, location, location group, property, or analytical group. - All filters are cumulative (ie. they are AND-ed together). The more filters you add, the fewer results will be exported. - An exit code of 0 indicates the export was successful. An exit code greater than 0 indicates an error. This allows for easy error checking when invoked from scripts. @@ -33,18 +33,55 @@ Two options are required to tell the tool how to access your AQUARIUS Samples in - The `/SamplesServer=` option sets the URL to the server, usually something like `/SamplesServer=https://mydomain.aqsamples.com`. - The `/SamplesApiToken=` option sets the API token used to authenticate your account. Navigate to https://[your_account].aqsamples.com/api/ and follow the instructions to get the token. - ## Authentication with AQUARIUS Time-Series Three options are required to tell the tool how to access your AQUARIUS Time-Series system. - The `/TimeSeriesServer=` option sets the name of the AQTS server, usually something like `/TimeSeriesServer=https://myappserver`. -- The `/TimeSeriesUsername=` and `TimeSeriesPassword` options set the AQTS credentials to use for uploading the generated reports. +- The `/TimeSeriesUsername=` and `/TimeSeriesPassword=` options set the AQTS credentials to use for uploading the generated reports. + +## Filtering the exported observations + +- You cannot mix `/LocationId=` and `/LocationGroupId=` options, but at least one must be specified. +- You cannot mix `/AnalyticalGroupId=` and `/ObservedPropertyId=` options, but at least one must be specified. +- All the other filter options are AND-ed together, to reduce the observations exported using the template. + +## Controlling the AQTS attachment filename + +The exported observations spreadsheet is downloaded from AQUARIUS Samples and uploaded to the equivalent AQUARIUS Time-Series +location as a location attachment with the specified location tags applied. + +The `/AttachmentFilename=` option controls the name of the uploaded location attachment. + +Supported \{replacement patterns} include: + +| Pattern | Replaced with: | +|---|---| +| `{Template}` | The name of the AQUARIUS Samples Observation Export template. | +| `{Location}` | The AQUARIUS Time-Series location identifier. | +| `{Time`*:format*`}` | The `/ExportTime` value.

The optional *format* is a [.NET DateTimeOffset format string](https://docs.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
If omitted, the default format of `yyyy-MM-dd` is used.| + +The default `/AttachmentFilename=` value is `{Template}-{Location}.xlsx`. + +The resulting attachment filename will also interact with the `/DeleteExistingAttachment=` option. +By default, any existing attachments at the location with the same filename will be deleted before the new attachment is uploaded, to avoid cluttering the location's attachment list. + +## Easy integration with WebPortal security using the `/AttachmentTags=` option + +The `/AttachmentTags=` option can be set multiple times to apply as many tags as needed to all the uploaded attachments. + +- Tags are specified in key:value format, with a colon separating the two parts. +- The tag must be configured with AppliedToAttachments = true. +- If a tag has its ValueType of None, then no value portion is required. + +AQUARIUS WebPortal can be configured to display attachments with specific tag patterns to specific view groups. + +See your AQUARIUS WebPortal Admin Guide for configuration details. ### `/help` screen ``` -Export observations from AQUARIUS Samples using a spreadsheet template and into AQUARIUS Time-Series. +Export observations from AQUARIUS Samples using a spreadsheet template into AQUARIUS Time-Series locations as attachments. usage: ObservationReportExporter [-option=value] [@optionsFile] ... @@ -60,17 +97,18 @@ Supported -option=value settings (/option=value works too): -TimeSeriesPassword AQTS password =========================== Export options: + -DryRun When true, don't export and upload reports, just validate what would be done. [default: False] -ExportTemplateName The Observation Export Spreadsheet Template to use for all exports. - -AttachmentFilename Name of the generated report. Existing attachments with the same name will be deleted. [default: Report.xlsx] + -AttachmentFilename Filename of the exported attachment. [default: {Template}-{Location}.xlsx] -AttachmentTags Uploaded attachments will have these tag values applies, in key:value format. -DeleteExistingAttachments Delete any existing location attachments with the same name. [default: True] - -DryRun When true, don't export and upload reports, just validate what would be done. [default: False] + -ExportTime The timestamp used for all {Time} pattern substitutions. [default: The current time] =========================== Cumulative filter options: (ie. AND-ed together). Can be set multiple times. - -StartTime Include observations after this time. - -EndTime Include observations before this time. + -StartTime Include observations after this time. [default: Start of record] + -EndTime Include observations before this time. [default: End of record] -LocationId Observations matching these sampling locations. - -LocationGroupId Observations matching these sample location groups. + -LocationGroupId Observations matching these sampling location groups. -AnalyticalGroupId Observations matching these analytical groups. -ObservedPropertyId Observations matching these observed properties.