diff --git a/.github/workflows/dotnet-test.yml b/.github/workflows/dotnet-test.yml index 1187e00..77e17f7 100644 --- a/.github/workflows/dotnet-test.yml +++ b/.github/workflows/dotnet-test.yml @@ -26,7 +26,7 @@ jobs: run: dotnet build '${{ vars.BUILD_SOLUTION }}' -c ${{ matrix.config }} - name: Test solution with ${{ matrix.config }} configuration run: | - dotnet test '${{ vars.BUILD_SOLUTION }}' --no-build -c ${{ matrix.config }} --verbosity normal --logger trx --results-directory "TestResults-${{ matrix.os }}-${{ matrix.config }}" -- RunConfiguration.TestSessionTimeout=${{ vars.MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_MILLISECONDS }} + dotnet test '${{ vars.BUILD_SOLUTION }}' --no-build -c ${{ matrix.config }} --verbosity normal --logger junit --results-directory "TestResults-${{ matrix.os }}-${{ matrix.config }}" -- RunConfiguration.TestSessionTimeout=${{ vars.MIGRATIONSDK_TEST_CANCELLATION_TIMEOUT_MILLISECONDS }} - name: Upload test results # Use always() to always run this step to publish test results when there are test failures if: ${{ always() }} @@ -34,4 +34,4 @@ jobs: with: name: dotnet-results-${{ matrix.os }}-${{ matrix.config }} path: TestResults-${{ matrix.os }}-${{ matrix.config }} - if-no-files-found: error \ No newline at end of file + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 8543492..5085114 100644 --- a/.gitignore +++ b/.gitignore @@ -80,8 +80,6 @@ instance/ # Scrapy stuff: .scrapy -# Sphinx documentation -docs/_build/ # PyBuilder .pybuilder/ @@ -179,14 +177,6 @@ launchSettings.json UpgradeLog.htm *.*.ini *.*.json -/src/Python/Documentation/_build -/src/Python/Documentation/_static -/src/Python/Python.pyproj.user -/src/Documentation/_python -/src/Python/Documentation/generated -/tests/Python.TestApplication/manifest.json -/.git2gus -/src/Python/scripts/publish-package.ps1 # Public repo ignores .github/pull_request_template.md \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props index b170f62..198a9b8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable true true - 4.2.0 + 4.3.0 Salesforce, Inc. Salesforce, Inc. Copyright (c) 2024, Salesforce, Inc. and its licensors diff --git a/examples/Csharp.ExampleApplication/Hooks/Filters/SharedCustomViewFilter.cs b/examples/Csharp.ExampleApplication/Hooks/Filters/SharedCustomViewFilter.cs new file mode 100644 index 0000000..7bb7425 --- /dev/null +++ b/examples/Csharp.ExampleApplication/Hooks/Filters/SharedCustomViewFilter.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Content; +using Tableau.Migration.Engine; +using Tableau.Migration.Engine.Hooks.Filters; +using Tableau.Migration.Resources; + +namespace Csharp.ExampleApplication.Hooks.Filters +{ + #region class + public class SharedCustomViewFilter : ContentFilterBase + { + public SharedCustomViewFilter( + ISharedResourcesLocalizer localizer, + ILogger> logger) + : base(localizer, logger) { } + + public override bool ShouldMigrate(ContentMigrationItem item) + { + return !item.SourceItem.Shared; + } + } + #endregion +} diff --git a/examples/Csharp.ExampleApplication/Hooks/Transformers/CustomViewDefaultUsersTransformer.cs b/examples/Csharp.ExampleApplication/Hooks/Transformers/CustomViewDefaultUsersTransformer.cs new file mode 100644 index 0000000..070dcef --- /dev/null +++ b/examples/Csharp.ExampleApplication/Hooks/Transformers/CustomViewDefaultUsersTransformer.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Hooks.Transformers; +using Tableau.Migration.Resources; + +namespace Csharp.ExampleApplication.Hooks.Transformers +{ + #region class + public class CustomViewExcludeDefaultUserTransformer( + ISharedResourcesLocalizer localizer, + ILogger logger) + : ContentTransformerBase(localizer, logger) + { + public IList ExcludeUsernames { get; } = new List() {"User1", "User2"}; + + + private readonly ILogger? _logger = logger; + + public override async Task TransformAsync(IPublishableCustomView itemToTransform, CancellationToken cancel) + { + var newDefaultUsers = itemToTransform.DefaultUsers.Where(user => !ExcludeUsernames.Contains(user.Name)).ToList(); + + itemToTransform.DefaultUsers = newDefaultUsers; + + _logger?.LogInformation( + @"Excluding default users {newDefaultUsers}", + newDefaultUsers); + + return await Task.FromResult(itemToTransform); + } + } + #endregion +} \ No newline at end of file diff --git a/examples/Csharp.ExampleApplication/MyMigrationApplication.cs b/examples/Csharp.ExampleApplication/MyMigrationApplication.cs index b2cb0bb..bd1b73b 100644 --- a/examples/Csharp.ExampleApplication/MyMigrationApplication.cs +++ b/examples/Csharp.ExampleApplication/MyMigrationApplication.cs @@ -113,6 +113,10 @@ public async Task StartAsync(CancellationToken cancel) #region UnlicensedUsersFilter-Registration _planBuilder.Filters.Add(); #endregion + + #region SharedCustomViewFilter-Registration + _planBuilder.Filters.Add(); + #endregion // Add post-publish hooks #region UpdatePermissionsHook-Registration @@ -138,6 +142,10 @@ public async Task StartAsync(CancellationToken cancel) #region StartAtTransformer-Registration _planBuilder.Transformers.Add, ICloudExtractRefreshTask>(); #endregion + + #region CustomViewDefaultUsersTransformer-Registration + _planBuilder.Transformers.Add(); + #endregion // Add migration action completed hooks #region LogMigrationActionsHook-Registration diff --git a/examples/Csharp.ExampleApplication/Program.cs b/examples/Csharp.ExampleApplication/Program.cs index 1b56c72..48c1d6c 100644 --- a/examples/Csharp.ExampleApplication/Program.cs +++ b/examples/Csharp.ExampleApplication/Program.cs @@ -68,6 +68,10 @@ public static IServiceCollection AddCustomizations(this IServiceCollection servi #region UnlicensedUsersFilter-DI services.AddScoped(); #endregion + + #region SharedCustomViewFilter-DI + services.AddScoped(); + #endregion #region UpdatePermissionsHook-DI services.AddScoped(typeof(UpdatePermissionsHook<,>)); @@ -90,6 +94,10 @@ public static IServiceCollection AddCustomizations(this IServiceCollection servi #region StartAtTransformer-DI services.AddScoped(typeof(SimpleScheduleStartAtTransformer<>)); #endregion + + #region CustomViewDefaultUsersTransformer-DI + services.AddScoped(); + #endregion #region LogMigrationActionsHook-DI services.AddScoped(); diff --git a/examples/Python.ExampleApplication/Hooks/Filters/shared_custom_view_filter.py b/examples/Python.ExampleApplication/Hooks/Filters/shared_custom_view_filter.py new file mode 100644 index 0000000..b7f8acd --- /dev/null +++ b/examples/Python.ExampleApplication/Hooks/Filters/shared_custom_view_filter.py @@ -0,0 +1,11 @@ +from tableau_migration import ( + ICustomView, + ContentMigrationItem, + ContentFilterBase) + + +class SharedCustomViewFilter(ContentFilterBase[ICustomView]): + def should_migrate(self, item: ContentMigrationItem[ICustomView]) -> bool: + if item.source_item.shared == True: + return False + return True \ No newline at end of file diff --git a/examples/Python.ExampleApplication/Hooks/mappings/project_rename_mapping.py b/examples/Python.ExampleApplication/Hooks/mappings/project_rename_mapping.py index 5607abb..ea530ee 100644 --- a/examples/Python.ExampleApplication/Hooks/mappings/project_rename_mapping.py +++ b/examples/Python.ExampleApplication/Hooks/mappings/project_rename_mapping.py @@ -11,6 +11,6 @@ def map(self, ctx: ContentMappingContext[IProject]) -> ContentMappingContext[IPr new_location = ctx.content_item.location.rename("Production") - ctx.map_to(new_location) + ctx = ctx.map_to(new_location) return ctx \ No newline at end of file diff --git a/examples/Python.ExampleApplication/Hooks/transformers/custom_view_default_users_transformer.py b/examples/Python.ExampleApplication/Hooks/transformers/custom_view_default_users_transformer.py new file mode 100644 index 0000000..bd393db --- /dev/null +++ b/examples/Python.ExampleApplication/Hooks/transformers/custom_view_default_users_transformer.py @@ -0,0 +1,14 @@ +from tableau_migration import ( + ContentTransformerBase, + IContentReference, + IPublishableCustomView +) + +class CustomViewDefaultUsersTransformer(ContentTransformerBase[IPublishableCustomView]): + + #Pass in list of users retrieved from Users API + default_users = [] + + def transform(self, itemToTransform: IPublishableCustomView) -> IPublishableCustomView: + itemToTransform.default_users = self.default_users + return itemToTransform \ No newline at end of file diff --git a/examples/Python.ExampleApplication/Python.ExampleApplication.py b/examples/Python.ExampleApplication/Python.ExampleApplication.py index 56e5183..e2bc15c 100644 --- a/examples/Python.ExampleApplication/Python.ExampleApplication.py +++ b/examples/Python.ExampleApplication/Python.ExampleApplication.py @@ -8,16 +8,15 @@ import configparser # configuration parser import os # environment variables -import sys # system utility import tableau_migration # Tableau Migration SDK -import print_result +from print_result import print_result from threading import Thread # threading from tableau_migration import ( MigrationManifestSerializer, MigrationManifest - ) +) serializer = MigrationManifestSerializer() @@ -40,12 +39,14 @@ def migrate(): server_url = config['SOURCE']['URL'], site_content_url = config['SOURCE']['SITE_CONTENT_URL'], access_token_name = config['SOURCE']['ACCESS_TOKEN_NAME'], - access_token = os.environ.get('TABLEAU_MIGRATION_SOURCE_TOKEN', config['SOURCE']['ACCESS_TOKEN'])) \ + access_token = os.environ.get('TABLEAU_MIGRATION_SOURCE_TOKEN', config['SOURCE']['ACCESS_TOKEN']), + create_api_simulator = os.environ.get('TABLEAU_MIGRATION_SOURCE_SIMULATION', 'False') == 'True') \ .to_destination_tableau_cloud( pod_url = config['DESTINATION']['URL'], site_content_url = config['DESTINATION']['SITE_CONTENT_URL'], access_token_name = config['DESTINATION']['ACCESS_TOKEN_NAME'], - access_token = os.environ.get('TABLEAU_MIGRATION_DESTINATION_TOKEN', config['DESTINATION']['ACCESS_TOKEN'])) \ + access_token = os.environ.get('TABLEAU_MIGRATION_DESTINATION_TOKEN', config['DESTINATION']['ACCESS_TOKEN']), + create_api_simulator = os.environ.get('TABLEAU_MIGRATION_DESTINATION_SIMULATION', 'False') == 'True') \ .for_server_to_cloud() \ .with_tableau_id_authentication_type() \ .with_tableau_cloud_usernames(config['USERS']['EMAIL_DOMAIN']) @@ -101,7 +102,7 @@ def load_manifest(manifest_path: str) -> MigrationManifest | None: while not done: try: migration_thread.join(1) - sys.exit(0) + done = True except KeyboardInterrupt: # Ctrl+C was caught, request migration to cancel. print("Caught Ctrl+C, shutting down...") diff --git a/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj b/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj index f2f9cbe..459ae6a 100644 --- a/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj +++ b/examples/Python.ExampleApplication/Python.ExampleApplication.pyproj @@ -30,6 +30,7 @@ + @@ -37,6 +38,7 @@ + diff --git a/examples/Python.ExampleApplication/config.ini b/examples/Python.ExampleApplication/config.ini index 325a2e8..a7ca03c 100644 --- a/examples/Python.ExampleApplication/config.ini +++ b/examples/Python.ExampleApplication/config.ini @@ -2,11 +2,13 @@ URL = http://server SITE_CONTENT_URL = ACCESS_TOKEN_NAME = MyServerTokenName +ACCESS_TOKEN = [DESTINATION] URL = https://pod.online.tableau.com SITE_CONTENT_URL = mycloudsite ACCESS_TOKEN_NAME = MyCloudTokenName +ACCESS_TOKEN = [USERS] EMAIL_DOMAIN = mycompany.com diff --git a/examples/Python.ExampleApplication/print_result.py b/examples/Python.ExampleApplication/print_result.py index aced451..5e6dd00 100644 --- a/examples/Python.ExampleApplication/print_result.py +++ b/examples/Python.ExampleApplication/print_result.py @@ -1,11 +1,10 @@ -import tableau_migration from tableau_migration.migration import PyMigrationResult from tableau_migration import IMigrationManifestEntry, MigrationManifestEntryStatus from Tableau.Migration.Engine.Pipelines import ServerToCloudMigrationPipeline -def print_result(self, result: PyMigrationResult): +def print_result(result: PyMigrationResult): """Prints the result of a migration.""" - self.logger.info(f'Result: {result.status}') + print(f'Result: {result.status}') for pipeline_content_type in ServerToCloudMigrationPipeline.ContentTypes: content_type = pipeline_content_type.ContentType @@ -41,4 +40,4 @@ def print_result(self, result: PyMigrationResult): \t{count_pending}/{count_total} pending ''' - self.logger.info(output) \ No newline at end of file + print(output) \ No newline at end of file diff --git a/examples/Python.ExampleApplication/requirements.txt b/examples/Python.ExampleApplication/requirements.txt index b7b0300..d8f075b 100644 --- a/examples/Python.ExampleApplication/requirements.txt +++ b/examples/Python.ExampleApplication/requirements.txt @@ -1,8 +1,3 @@ -setuptools==70.1.1 configparser==7.0.0 tableau_migration -cffi==1.16.0 -pycparser==2.22 -pythonnet==3.0.3 -typing_extensions==4.12.2 python-dotenv==1.0.1 diff --git a/src/Documentation/api-csharp/index.md b/src/Documentation/api-csharp/index.md index e34a3fe..2d7bf32 100644 --- a/src/Documentation/api-csharp/index.md +++ b/src/Documentation/api-csharp/index.md @@ -6,40 +6,7 @@ Welcome to the C# API Reference for the Migration SDK. The following code samples are for writing a simple migration app using the Migration SDK. For details on configuring and customizing the Migration SDK to your specific needs, see [Articles](~/articles/index.md) and [Code Samples](~/samples/index.md). -### [Program.cs](#tab/program-cs) - -[!code-csharp[](../../../examples/Csharp.ExampleApplication/Program.cs#namespace)] - -### [Startup code](#tab/startup-cde) - -[!code-csharp[](../../../examples/Csharp.ExampleApplication/MyMigrationApplication.cs#namespace)] - -### [Config classes](#tab/config-classes) - -[!code-csharp[](../../../examples/Csharp.ExampleApplication/Config/MyMigrationApplicationOptions.cs#namespace)] - -[!code-csharp[](../../../examples/Csharp.ExampleApplication/Config/EndpointOptions.cs#namespace)] - -### [Config file](#tab/appsettings) - -```json -{ - "source": { - "serverUrl": "http://server", - "siteContentUrl": "", - "accessTokenName": "my server token name", - "accessToken": "my-secret-server-pat" - }, - "destination": { - "serverUrl": "https://pod.online.tableau.com", - "siteContentUrl": "site-name", - "accessTokenName": "my cloud token name", - "accessToken": "my-secret-cloud-pat" - } -} -``` - ---- +[!include[](~/includes/csharp-getting-started.md)] ## Suggested Reading diff --git a/src/Documentation/api-python/index.md b/src/Documentation/api-python/index.md index e008e3a..55371fa 100644 --- a/src/Documentation/api-python/index.md +++ b/src/Documentation/api-python/index.md @@ -24,19 +24,4 @@ There are advanced features of the Migration SDK that the Python Wrapper cannot The following code samples are for writing a simple migration app using the Migration SDK. For details on configuring and customizing the Migration SDK to your specific needs, see [Articles](~/articles/index.md) and [Code Samples](~/samples/index.md). -### [Startup Script](#tab/startup) - -[!code-python[](../../../examples/Python.ExampleApplication/Python.ExampleApplication.py)] - -### [config.ini](#tab/config) - -> [!Important] -> The values below should not be quoted. So ***no*** `'` or `"`. - -[!code-ini[](../../../examples/Python.ExampleApplication/config.ini)] - -### [requirements.txt](#tab/reqs) - -[!code-text[](../../../examples/Python.ExampleApplication/requirements.txt#L3-)] - ---- +[!include[](~/includes/python-getting-started.md)] diff --git a/src/Documentation/articles/configuration.md b/src/Documentation/articles/configuration.md index 02bd091..5f7c6f0 100644 --- a/src/Documentation/articles/configuration.md +++ b/src/Documentation/articles/configuration.md @@ -448,6 +448,20 @@ Supported Content Types: *Description:* The Migration SDK uses [Microsoft.Extensions.Http.Resilience](https://learn.microsoft.com/en-us/dotnet/core/resilience) as a resilience and transient-fault layer. The SDK uses the **PerFileTransferRequestTimeout** property to define the maximum duration of FileTransfer requests. + +### Network.UserAgentComment + +*Reference:* [`NetworkOptions.UserAgentComment`](xref:Tableau.Migration.Config.NetworkOptions#Tableau_Migration_Config_NetworkOptions_UserAgentComment). + +*Default:* [`NetworkOptions.Defaults.USER_AGENT_COMMENT`](xref:Tableau.Migration.Config.NetworkOptions.Defaults#Tableau_Migration_Config_NetworkOptions_Defaults_USER_AGENT_COMMENT). + +*Python Environment Variable:* `MigrationSDK__Network__UserAgentComment` + +*Reload on Edit?:* **No**. Any changes to this configuration will reflect on the next time the application starts. + +*Description:* The Migration SDK appends the **UserAgentComment** property to the User-Agent header in all HTTP requests. This property is only used to assist in server-side debugging and it not typically set. + + ### DefaultPermissionsContentTypes.UrlSegments *Reference:* [`DefaultPermissionsContentTypeOptions.UrlSegments`](xref:Tableau.Migration.Config.DefaultPermissionsContentTypeOptions#Tableau_Migration_Config_DefaultPermissionsContentTypeOptions_UrlSegments). diff --git a/src/Documentation/articles/cv_file.md b/src/Documentation/articles/cv_file.md new file mode 100644 index 0000000..9a5a607 --- /dev/null +++ b/src/Documentation/articles/cv_file.md @@ -0,0 +1,32 @@ +# Anatomy of a Custom View File + +The Migration SDK downloads Custom View definition files during the migration process. There files are in JSON format. This is what a Custom View File looks like: + +```json +[ + ..... + { + "isSourceView": true, + "viewName": "View 1", + "tcv": "{base-64 custom view xml content with + signs replaced by -}" + } + ..... +] + +``` + +## Getting the Custom View Definition from the Custom View File + +In the JSON file, the field `tcv` contains the XML definition of a custom view. This field is encoded as a Base64 string, with `+` characters replaced by `-`. + +To decode the `tcv` value + +1. Replace all the `-` characters in the string with `+`. +2. Decode the resulting Base64 string. +3. Get the XML Custom View Definition. + +To encode the `tcv` value + +1. Make any changes to the Custom View Definition. +2. Encode the XML into a Base64 string. +3. Replace all the `+` characters in the string with `-`. diff --git a/src/Documentation/articles/hooks/python_hook_update.md b/src/Documentation/articles/hooks/python_hook_update.md index bb6fcbe..862cab1 100644 --- a/src/Documentation/articles/hooks/python_hook_update.md +++ b/src/Documentation/articles/hooks/python_hook_update.md @@ -54,8 +54,8 @@ class FilterBob(ContentFilterBase[IUser]): ### Version 3 -> Version 4+ Registration Diff ```diff -- plan_builder.filters.add(IUser, FilterBob()) -+ plan_builder.filters.add(FilterBob()) +- plan_builder.filters.add(IUser, FilterBob) ++ plan_builder.filters.add(FilterBob) ``` ## Mappings diff --git a/src/Documentation/articles/index.md b/src/Documentation/articles/index.md index a8f3304..1fa3e76 100644 --- a/src/Documentation/articles/index.md +++ b/src/Documentation/articles/index.md @@ -9,6 +9,12 @@ You can develop your migration application using one of the supported languages - [Python](https://www.python.org/) - C# using the [.NET Framework](https://dotnet.microsoft.com/en-us/learn/dotnet/what-is-dotnet-framework) +## Versioning + +The Migration SDK uses [semantic versioning](https://semver.org). + +![Versioning](../images/versioning.svg){width=40%} + ## Prerequisites To develop your application using the [Migration SDK](https://github.com/tableau/tableau-migration-sdk), you should @@ -23,30 +29,42 @@ To develop your application using the [Migration SDK](https://github.com/tableau - Install a [.NET Runtime](https://dotnet.microsoft.com/en-us/download). - Install the Migration SDK -## [Python](#tab/Python) +### [Python](#tab/Python) + +Install using PIP - Install using PIP - - [PIP CLI](https://pip.pypa.io/en/stable/cli/pip_install): `pip install tableau_migration` +- [PIP CLI](https://pip.pypa.io/en/stable/cli/pip_install): `pip install tableau_migration` -## [C#](#tab/CSharp) +### [C#](#tab/CSharp) - Install using NuGet - - [dotnet CLI](https://learn.microsoft.com/en-us/nuget/quickstart/install-and-use-a-package-using-the-dotnet-cli): `dotnet add package Tableau.Migration` - - [Nuget Package Manager](https://learn.microsoft.com/en-us/nuget/quickstart/install-and-use-a-package-in-visual-studio): Search for `Tableau.Migration`. +Install using NuGet + +- [dotnet CLI](https://learn.microsoft.com/en-us/nuget/quickstart/install-and-use-a-package-using-the-dotnet-cli): `dotnet add package Tableau.Migration` +- [Nuget Package Manager](https://learn.microsoft.com/en-us/nuget/quickstart/install-and-use-a-package-in-visual-studio): Search for `Tableau.Migration`. --- -- Use the sample code in one of the [Reference](#getting-started-and-api-reference) sections to get started. +- Use the sample code in [Example startup code](#example-startup-code) to get started. - Use the [Resource](#resources) sections to further customize your application. -## Getting started and API Reference +## Example startup code -- [Python API Reference](~/api-python/index.md) : Getting started sample and the complete Python API Reference for comprehensive documentation. -- [C# API Reference](~/api-csharp/index.md): Getting started sample and the complete C# API Reference for detailed documentation. +The following code samples are for writing a simple migration app using the Migration SDK. For details on configuring and customizing the Migration SDK to your specific needs, see the other articles and [Code Samples](~/samples/index.md). + +### [Python](#tab/Python) + +[!include[](~/includes/python-getting-started.md)] + +### [C#](#tab/CSharp) + +[!include[](~/includes/csharp-getting-started.md)] + +--- ## Resources -- [Articles](~/articles/index.md): Articles covering a range of topics, including customization of the Migration SDK. +- [Python API Reference](~/api-python/index.md) : Getting started sample and the complete Python API Reference for comprehensive documentation. +- [C# API Reference](~/api-csharp/index.md): Getting started sample and the complete C# API Reference for detailed documentation. - [Code Samples](~/samples/index.md): Code samples to kickstart your development process. ## Source Code diff --git a/src/Documentation/articles/toc.yml b/src/Documentation/articles/toc.yml index a80c5b9..1e818aa 100644 --- a/src/Documentation/articles/toc.yml +++ b/src/Documentation/articles/toc.yml @@ -15,6 +15,8 @@ href: hooks/python_hook_update.md - name: User Authentication href: user_authentication.md +- name: Custom View File + href: cv_file.md - name: Dependency Injection href: dependency_injection.md - name: Troubleshooting diff --git a/src/Documentation/images/versioning.svg b/src/Documentation/images/versioning.svg new file mode 100644 index 0000000..f5d604d --- /dev/null +++ b/src/Documentation/images/versioning.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/Documentation/includes/csharp-getting-started.md b/src/Documentation/includes/csharp-getting-started.md new file mode 100644 index 0000000..85a7c01 --- /dev/null +++ b/src/Documentation/includes/csharp-getting-started.md @@ -0,0 +1,34 @@ +### [Program.cs](#tab/program-cs) + +[!code-csharp[](../../../examples/Csharp.ExampleApplication/Program.cs#namespace)] + +### [Startup code](#tab/startup-cde) + +[!code-csharp[](../../../examples/Csharp.ExampleApplication/MyMigrationApplication.cs#namespace)] + +### [Config classes](#tab/config-classes) + +[!code-csharp[](../../../examples/Csharp.ExampleApplication/Config/MyMigrationApplicationOptions.cs#namespace)] + +[!code-csharp[](../../../examples/Csharp.ExampleApplication/Config/EndpointOptions.cs#namespace)] + +### [Config file](#tab/appsettings) + +```json +{ + "source": { + "serverUrl": "http://server", + "siteContentUrl": "", + "accessTokenName": "my-server-token-name", + "accessToken": "my-secret-server-pat" + }, + "destination": { + "serverUrl": "https://pod.online.tableau.com", + "siteContentUrl": "site-name", + "accessTokenName": "my-cloud-token-name", + "accessToken": "my-secret-cloud-pat" + } +} +``` + +--- diff --git a/src/Documentation/includes/python-getting-started.md b/src/Documentation/includes/python-getting-started.md new file mode 100644 index 0000000..eae84a7 --- /dev/null +++ b/src/Documentation/includes/python-getting-started.md @@ -0,0 +1,16 @@ +### [Startup Script](#tab/startup) + +[!code-python[](../../../examples/Python.ExampleApplication/Python.ExampleApplication.py)] + +### [config.ini](#tab/config) + +> [!Important] +> The values below should not be quoted. So ***no*** `'` or `"`. + +[!code-ini[](../../../examples/Python.ExampleApplication/config.ini)] + +### [requirements.txt](#tab/reqs) + +[!code-text[](../../../examples/Python.ExampleApplication/requirements.txt#L3-)] + +--- diff --git a/src/Documentation/samples/filters/filter_custom_views_by_shared.md b/src/Documentation/samples/filters/filter_custom_views_by_shared.md new file mode 100644 index 0000000..df28d97 --- /dev/null +++ b/src/Documentation/samples/filters/filter_custom_views_by_shared.md @@ -0,0 +1,36 @@ +# Sample: Filter Custom Views by Shared Status + +In this example, the custom views currently shared to all users are excluded from migration. + +# [Python](#tab/Python) + +#### Filter Class + +[!code-python[](../../../../examples/Python.ExampleApplication/hooks/filters/shared_custom_view_filter.py)] + +#### Registration + +[Learn more.](~/samples/index.md?tabs=Python#hook-registration) + +[//]: <> (Adding this as code as regions are not supported in Python snippets) +```Python +plan_builder.filters.add(SharedCustomViewFilter) +``` + +# [C#](#tab/CSharp) + +#### Filter Class + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Hooks/Filters/SharedCustomViewFilter.cs#class)] + +#### Registration + +[Learn more.](~/samples/index.md?tabs=Python#hook-registration) + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/MyMigrationApplication.cs#SharedCustomViewFilter-Registration)] + +#### Dependency Injection + +[Learn more.](~/articles/dependency_injection.md) + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Program.cs#SharedCustomViewFilter-DI)] diff --git a/src/Documentation/samples/filters/filter_projects_by_name.md b/src/Documentation/samples/filters/filter_projects_by_name.md index dfffc55..79f0583 100644 --- a/src/Documentation/samples/filters/filter_projects_by_name.md +++ b/src/Documentation/samples/filters/filter_projects_by_name.md @@ -10,13 +10,14 @@ In this example, the project named `Default` is filtered out. #### Registration -[Learn more.](~/samples/index.md?tabs=Python#hook-registration) - [//]: <> (Adding this as code as regions are not supported in Python snippets) + ```Python plan_builder.filters.add(DefaultProjectFilter) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + # [C#](#tab/CSharp) #### Filter Class diff --git a/src/Documentation/samples/filters/filter_users_by_site_role.md b/src/Documentation/samples/filters/filter_users_by_site_role.md index 12f2e79..9bba5f5 100644 --- a/src/Documentation/samples/filters/filter_users_by_site_role.md +++ b/src/Documentation/samples/filters/filter_users_by_site_role.md @@ -10,13 +10,14 @@ In this example, all unlicensed users are excluded from migration. #### Registration -[Learn more.](~/samples/index.md?tabs=Python#hook-registration) - [//]: <> (Adding this as code as regions are not supported in Python snippets) + ```Python plan_builder.filters.add(UnlicensedUserFilter) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + # [C#](#tab/CSharp) #### Filter Class diff --git a/src/Documentation/samples/filters/index.md b/src/Documentation/samples/filters/index.md index d8e7b6d..ee4641d 100644 --- a/src/Documentation/samples/filters/index.md +++ b/src/Documentation/samples/filters/index.md @@ -10,3 +10,5 @@ The following samples cover some common scenarios: - [Sample: Filter projects by name](~/samples/filters/filter_projects_by_name.md) - [Sample: Filter users by site role](~/samples/filters/filter_users_by_site_role.md) + +- [Sample: Filter custom views by shared setting](~/samples/filters/filter_custom_views_by_shared.md) diff --git a/src/Documentation/samples/filters/toc.yml b/src/Documentation/samples/filters/toc.yml index 47de512..7cbe411 100644 --- a/src/Documentation/samples/filters/toc.yml +++ b/src/Documentation/samples/filters/toc.yml @@ -1,4 +1,6 @@ - name: Filter projects by name href: filter_projects_by_name.md - name: Filter users by SiteRole - href: filter_users_by_site_role.md \ No newline at end of file + href: filter_users_by_site_role.md +- name: Filter Custom Views by 'Shared' flag + href: filter_custom_views_by_shared.md \ No newline at end of file diff --git a/src/Documentation/samples/mappings/change_projects.md b/src/Documentation/samples/mappings/change_projects.md index bfaa1b0..a3e49a5 100644 --- a/src/Documentation/samples/mappings/change_projects.md +++ b/src/Documentation/samples/mappings/change_projects.md @@ -12,14 +12,15 @@ Both the C# and Python mapping classes inherit from a base class that handles mo #### Registration -[Learn more.](~/samples/index.md?tabs=Python#hook-registration) - [//]: <> (Adding this as code as regions are not supported in Python snippets) + ```Python plan_builder.mappings.add(ChangeProjectMappingForWorkbooks) plan_builder.mappings.add(ChangeProjectMappingForDataSources) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + # [C#](#tab/CSharp) #### Mapping Class diff --git a/src/Documentation/samples/mappings/rename_projects.md b/src/Documentation/samples/mappings/rename_projects.md index fdb178a..2e37c32 100644 --- a/src/Documentation/samples/mappings/rename_projects.md +++ b/src/Documentation/samples/mappings/rename_projects.md @@ -10,13 +10,14 @@ In this example, the source project named `Test` is renamed to `Production` on t #### Registration -[Learn more.](~/samples/index.md?tabs=Python#hook-registration) - [//]: <> (Adding this as code as regions are not supported in Python snippets) + ```Python -plan_builder.mappings.add(EmailDomainMapping) +plan_builder.mappings.add(ProjectRenameMapping) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + # [C#](#tab/CSharp) #### Mapping Class diff --git a/src/Documentation/samples/migration-action-completed/log_migration_actions.md b/src/Documentation/samples/migration-action-completed/log_migration_actions.md index de30f6e..da7d64e 100644 --- a/src/Documentation/samples/migration-action-completed/log_migration_actions.md +++ b/src/Documentation/samples/migration-action-completed/log_migration_actions.md @@ -12,14 +12,15 @@ To log migration action statuses in Python, you can utilize the following hook c ### Registration -For guidance on registering the hook, refer to the [documentation](~/samples/index.md?tabs=Python#hook-registration). - [//]: <> (Adding this as code as regions are not supported in python snippets) + ```Python plan_builder.hooks.add(LogMigrationActionsHookForUsers) plan_builder.hooks.add(LogMigrationActionsHookForGroups) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + # [C#](#tab/CSharp) ### Migration Action Completed Hook Class diff --git a/src/Documentation/samples/transformers/custom_view_default_users_transformer.md b/src/Documentation/samples/transformers/custom_view_default_users_transformer.md new file mode 100644 index 0000000..0e0ba7e --- /dev/null +++ b/src/Documentation/samples/transformers/custom_view_default_users_transformer.md @@ -0,0 +1,43 @@ +# Sample: Changing Default Users for Custom View + +This sample illustrates how to change the default users for a custom view + +Both the Python and C# transformer classes inherit from a base class that handles the core functionality, then create versions for `IPublishableCustomView`. + +## [Python](#tab/Python) + +### Transformer Class + +To implement the tag addition in Python, you can utilize the following transformer class: + +[!code-python[](../../../../examples/Python.ExampleApplication/hooks/transformers/custom_view_default_users_transformer.py)] + +### Registration + +For detailed instructions on registering the transformer, refer to the [documentation](~/samples/index.md?tabs=Python#hook-registration). + +[//]: <> (Adding this as code as regions are not supported in python snippets) +```Python +plan_builder.transformers.add(CustomViewDefaultUsersTransformer) +``` + +## [C#](#tab/CSharp) + +### Transformer Class + +In C#, the transformer class for adding tags is implemented as shown below: + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Hooks/Transformers/CustomViewDefaultUsersTransformer.cs#class)] + +### Registration + +To register the transformer in C#, follow the guidance provided in the [documentation](~/samples/index.md?tabs=CSharp#hook-registration). + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/MyMigrationApplication.cs#CustomViewDefaultUsersTransformer-Registration)] + +### Dependency Injection + +Learn more about dependency injection [here](~/articles/dependency_injection.md). + +[!code-csharp[](../../../../examples/Csharp.ExampleApplication/Program.cs#CustomViewDefaultUsersTransformer-DI)] + diff --git a/src/Documentation/samples/transformers/encrypt_extracts_transformer.md b/src/Documentation/samples/transformers/encrypt_extracts_transformer.md index dc0f6ac..3e0224a 100644 --- a/src/Documentation/samples/transformers/encrypt_extracts_transformer.md +++ b/src/Documentation/samples/transformers/encrypt_extracts_transformer.md @@ -14,14 +14,15 @@ To encrypt extracts in Python, you can use the following transformer class: ### Registration -Refer to the [documentation](~/samples/index.md?tabs=Python#hook-registration) for instructions on registering the transformer. - [//]: <> (Adding this as code as regions are not supported in python snippets) + ```Python plan_builder.transformers.add(EncryptExtractTransformerForDataSources) plan_builder.transformers.add(EncryptExtractTransformerForWorkbooks) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + ## [C#](#tab/CSharp) ### Transformer Class diff --git a/src/Documentation/samples/transformers/index.md b/src/Documentation/samples/transformers/index.md index 3d14737..d290ab6 100644 --- a/src/Documentation/samples/transformers/index.md +++ b/src/Documentation/samples/transformers/index.md @@ -6,4 +6,5 @@ The following samples cover some common scenarios: - [Sample: Add tags to content](~/samples/transformers/migrated_tag_transformer.md) - [Sample: Encrypt Extracts](~/samples/transformers/encrypt_extracts_transformer.md) -- [Sample: Adjust 'Start At' to Scheduled Tasks](~/samples/transformers/start_at_transformer.md) \ No newline at end of file +- [Sample: Adjust 'Start At' to Scheduled Tasks](~/samples/transformers/start_at_transformer.md) +- [Sample: Change default users for Custom Views](~/samples/transformers/custom_view_default_users_transformer.md) \ No newline at end of file diff --git a/src/Documentation/samples/transformers/migrated_tag_transformer.md b/src/Documentation/samples/transformers/migrated_tag_transformer.md index 59502a5..ebf1a3e 100644 --- a/src/Documentation/samples/transformers/migrated_tag_transformer.md +++ b/src/Documentation/samples/transformers/migrated_tag_transformer.md @@ -14,14 +14,15 @@ To implement the tag addition in Python, you can utilize the following transform ### Registration -For detailed instructions on registering the transformer, refer to the [documentation](~/samples/index.md?tabs=Python#hook-registration). - [//]: <> (Adding this as code as regions are not supported in python snippets) + ```Python plan_builder.transformers.add(MigratedTagTransformerForDataSources) plan_builder.transformers.add(MigratedTagTransformerForWorkbooks) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + ## [C#](#tab/CSharp) ### Transformer Class @@ -41,4 +42,3 @@ To register the transformer in C#, follow the guidance provided in the [document Learn more about dependency injection [here](~/articles/dependency_injection.md). [!code-csharp[](../../../../examples/Csharp.ExampleApplication/Program.cs#MigratedTagTransformer-DI)] - diff --git a/src/Documentation/samples/transformers/start_at_transformer.md b/src/Documentation/samples/transformers/start_at_transformer.md index efa75fa..921919b 100644 --- a/src/Documentation/samples/transformers/start_at_transformer.md +++ b/src/Documentation/samples/transformers/start_at_transformer.md @@ -14,13 +14,14 @@ To adjust the 'Start At' in Python, you can use the following transformer class: ### Registration -Refer to the [documentation](~/samples/index.md?tabs=Python#hook-registration) for instructions on registering the transformer. - [//]: <> (Adding this as code as regions are not supported in python snippets) + ```Python plan_builder.transformers.add(SimpleScheduleStartAtTransformer) ``` +See [hook registration](~/samples/index.md?tabs=Python#hook-registration) for more details. + ## [C#](#tab/CSharp) ### Transformer Class diff --git a/src/Documentation/samples/transformers/toc.yml b/src/Documentation/samples/transformers/toc.yml index aa9cdcc..248d958 100644 --- a/src/Documentation/samples/transformers/toc.yml +++ b/src/Documentation/samples/transformers/toc.yml @@ -4,3 +4,5 @@ href: encrypt_extracts_transformer.md - name: Adjust 'Start At' to Scheduled Tasks href: start_at_transformer.md +- name: Change default users for Custom Views + href: custom_view_default_users_transformer.md \ No newline at end of file diff --git a/src/Python/requirements.txt b/src/Python/requirements.txt index 2185e35..9c48404 100644 --- a/src/Python/requirements.txt +++ b/src/Python/requirements.txt @@ -3,4 +3,4 @@ pycparser==2.22 pythonnet==3.0.3 typing_extensions==4.12.2 pytest-env==1.1.3 -build=1.2.1 \ No newline at end of file +build==1.2.1 \ No newline at end of file diff --git a/src/Python/src/tableau_migration/__init__.py b/src/Python/src/tableau_migration/__init__.py index 80e7fc4..a7eba83 100644 --- a/src/Python/src/tableau_migration/__init__.py +++ b/src/Python/src/tableau_migration/__init__.py @@ -99,6 +99,7 @@ from tableau_migration.migration_content import PyConnection as IConnection # noqa: E402, F401 from tableau_migration.migration_content import PyConnectionsContent as IConnectionsContent # noqa: E402, F401 from tableau_migration.migration_content import PyContainerContent as IContainerContent # noqa: E402, F401 +from tableau_migration.migration_content import PyCustomView as ICustomView # noqa: E402, F401 from tableau_migration.migration_content import PyDataSource as IDataSource # noqa: E402, F401 from tableau_migration.migration_content import PyDataSourceDetails as IDataSourceDetails # noqa: E402, F401 from tableau_migration.migration_content import PyDescriptionContent as IDescriptionContent # noqa: E402, F401 @@ -107,6 +108,7 @@ from tableau_migration.migration_content import PyGroupUser as IGroupUser # noqa: E402, F401 from tableau_migration.migration_content import PyLabel as ILabel # noqa: E402, F401 from tableau_migration.migration_content import PyProject as IProject # noqa: E402, F401 +from tableau_migration.migration_content import PyPublishableCustomView as IPublishableCustomView # noqa: E402, F401 from tableau_migration.migration_content import PyPublishableDataSource as IPublishableDataSource # noqa: E402, F401 from tableau_migration.migration_content import PyPublishableGroup as IPublishableGroup # noqa: E402, F401 from tableau_migration.migration_content import PyPublishableWorkbook as IPublishableWorkbook # noqa: E402, F401 @@ -118,6 +120,7 @@ from tableau_migration.migration_content import PyWithDomain as IWithDomain # noqa: E402, F401 from tableau_migration.migration_content import PyWithOwner as IWithOwner # noqa: E402, F401 from tableau_migration.migration_content import PyWithTags as IWithTags # noqa: E402, F401 +from tableau_migration.migration_content import PyWithWorkbook as IWithWorkbook # noqa: E402, F401 from tableau_migration.migration_content import PyWorkbook as IWorkbook # noqa: E402, F401 from tableau_migration.migration_content import PyWorkbookDetails as IWorkbookDetails # noqa: E402, F401 from tableau_migration.migration_content_permissions import PyCapability as ICapability # noqa: E402, F401 diff --git a/src/Python/src/tableau_migration/migration_content.py b/src/Python/src/tableau_migration/migration_content.py index cf562af..c737cc6 100644 --- a/src/Python/src/tableau_migration/migration_content.py +++ b/src/Python/src/tableau_migration/migration_content.py @@ -15,6 +15,8 @@ """Wrapper for classes in Tableau.Migration.Content namespace.""" +from Tableau.Migration import IContentReference # noqa: E402, F401 + # region _generated from tableau_migration.migration import PyContentReference # noqa: E402, F401 @@ -37,6 +39,7 @@ IConnection, IConnectionsContent, IContainerContent, + ICustomView, IDataSource, IDataSourceDetails, IDescriptionContent, @@ -45,6 +48,7 @@ IGroupUser, ILabel, IProject, + IPublishableCustomView, IPublishableDataSource, IPublishableGroup, IPublishableWorkbook, @@ -56,6 +60,7 @@ IWithDomain, IWithOwner, IWithTags, + IWithWorkbook, IWorkbook, IWorkbookDetails ) @@ -145,6 +150,106 @@ def container(self) -> PyContentReference: """Gets the container for the content item. Relocating the content should be done through mapping.""" return None if self._dotnet.Container is None else PyContentReference(self._dotnet.Container) +class PyWithOwner(PyContentReference): + """Interface to be inherited by content items with owner.""" + + _dotnet_base = IWithOwner + + def __init__(self, with_owner: IWithOwner) -> None: + """Creates a new PyWithOwner object. + + Args: + with_owner: A IWithOwner object. + + Returns: None. + """ + self._dotnet = with_owner + + @property + def owner(self) -> PyContentReference: + """Gets or sets the owner for the content item.""" + return None if self._dotnet.Owner is None else PyContentReference(self._dotnet.Owner) + + @owner.setter + def owner(self, value: PyContentReference) -> None: + """Gets or sets the owner for the content item.""" + self._dotnet.Owner = None if value is None else value._dotnet + +class PyWithWorkbook(PyContentReference): + """Interface to be inherited by content items with workbook.""" + + _dotnet_base = IWithWorkbook + + def __init__(self, with_workbook: IWithWorkbook) -> None: + """Creates a new PyWithWorkbook object. + + Args: + with_workbook: A IWithWorkbook object. + + Returns: None. + """ + self._dotnet = with_workbook + + @property + def workbook(self) -> PyContentReference: + """Gets or sets the workbook for the content item.""" + return None if self._dotnet.Workbook is None else PyContentReference(self._dotnet.Workbook) + + @workbook.setter + def workbook(self, value: PyContentReference) -> None: + """Gets or sets the workbook for the content item.""" + self._dotnet.Workbook = None if value is None else value._dotnet + +class PyCustomView(PyWithOwner, PyWithWorkbook): + """The interface for a custom view content item.""" + + _dotnet_base = ICustomView + + def __init__(self, custom_view: ICustomView) -> None: + """Creates a new PyCustomView object. + + Args: + custom_view: A ICustomView object. + + Returns: None. + """ + self._dotnet = custom_view + + @property + def created_at(self) -> str: + """Gets the created timestamp.""" + return self._dotnet.CreatedAt + + @property + def updated_at(self) -> str: + """Gets the updated timestamp.""" + return self._dotnet.UpdatedAt + + @property + def last_accessed_at(self) -> str: + """Gets the last accessed timestamp.""" + return self._dotnet.LastAccessedAt + + @property + def shared(self) -> bool: + """Gets or sets whether the custom view is shared with all users (true) or private (false).""" + return self._dotnet.Shared + + @shared.setter + def shared(self, value: bool) -> None: + """Gets or sets whether the custom view is shared with all users (true) or private (false).""" + self._dotnet.Shared = value + + @property + def base_view_id(self) -> UUID: + """Gets the ID of the view that this custom view is based on.""" + return None if self._dotnet.BaseViewId is None else UUID(self._dotnet.BaseViewId.ToString()) + + @property + def base_view_name(self) -> str: + """Gets the name of the view that this custom view is based on.""" + return self._dotnet.BaseViewName + class PyPublishedContent(): """Interface for a content item that has metadata around publishing information.""" @@ -281,31 +386,6 @@ def tags(self, value: List[PyTag]) -> None: dotnet_collection.Add(x._dotnet) self._dotnet.Tags = dotnet_collection -class PyWithOwner(PyContentReference): - """Interface to be inherited by content items with owner.""" - - _dotnet_base = IWithOwner - - def __init__(self, with_owner: IWithOwner) -> None: - """Creates a new PyWithOwner object. - - Args: - with_owner: A IWithOwner object. - - Returns: None. - """ - self._dotnet = with_owner - - @property - def owner(self) -> PyContentReference: - """Gets or sets the owner for the content item.""" - return None if self._dotnet.Owner is None else PyContentReference(self._dotnet.Owner) - - @owner.setter - def owner(self, value: PyContentReference) -> None: - """Gets or sets the owner for the content item.""" - self._dotnet.Owner = None if value is None else value._dotnet - class PyDataSource(PyPublishedContent, PyDescriptionContent, PyExtractContent, PyWithTags, PyContainerContent, PyWithOwner): """Interface for a data source content item.""" @@ -561,6 +641,37 @@ def parent_project(self) -> PyContentReference: """Gets the parent project reference, or null if the project is a top-level project. Should be changed through mapping.""" return None if self._dotnet.ParentProject is None else PyContentReference(self._dotnet.ParentProject) +class PyPublishableCustomView(PyCustomView): + """Interface for the publishable version of ICustomView.""" + + _dotnet_base = IPublishableCustomView + + def __init__(self, publishable_custom_view: IPublishableCustomView) -> None: + """Creates a new PyPublishableCustomView object. + + Args: + publishable_custom_view: A IPublishableCustomView object. + + Returns: None. + """ + self._dotnet = publishable_custom_view + + @property + def default_users(self) -> List[PyContentReference]: + """The list of users for whom the Custom View is the default.""" + return [] if self._dotnet.DefaultUsers is None else [PyContentReference(x) for x in self._dotnet.DefaultUsers if x is not None] + + @default_users.setter + def default_users(self, value: List[PyContentReference]) -> None: + """The list of users for whom the Custom View is the default.""" + if value is None: + self._dotnet.DefaultUsers = DotnetList[IContentReference]() + else: + dotnet_collection = DotnetList[IContentReference]() + for x in filter(None,value): + dotnet_collection.Add(x._dotnet) + self._dotnet.DefaultUsers = dotnet_collection + class PyPublishableDataSource(PyDataSourceDetails, PyConnectionsContent): """Interface for a IDataSource that has been downloaded and has full information necessary for re-publishing.""" diff --git a/src/Python/tests/test_classes.py b/src/Python/tests/test_classes.py index 728c172..90151a8 100644 --- a/src/Python/tests/test_classes.py +++ b/src/Python/tests/test_classes.py @@ -257,6 +257,7 @@ def test_overloaded_missing(self): PyConnection, PyConnectionsContent, PyContainerContent, + PyCustomView, PyDataSource, PyDataSourceDetails, PyDescriptionContent, @@ -265,6 +266,7 @@ def test_overloaded_missing(self): PyGroupUser, PyLabel, PyProject, + PyPublishableCustomView, PyPublishableDataSource, PyPublishableGroup, PyPublishableWorkbook, @@ -276,6 +278,7 @@ def test_overloaded_missing(self): PyWithDomain, PyWithOwner, PyWithTags, + PyWithWorkbook, PyWorkbook, PyWorkbookDetails ) @@ -339,13 +342,14 @@ def test_overloaded_missing(self): from Tableau.Migration.Engine.Manifest import MigrationManifestEntryStatus _generated_class_data = [ - (PyContentLocation, None), + (PyContentLocation, [ "ForContentType" ]), (PyContentReference, None), (PyResult, [ "CastFailure" ]), (PyRestIdentifiable, None), (PyConnection, None), (PyConnectionsContent, None), (PyContainerContent, None), + (PyCustomView, None), (PyDataSource, [ "SetLocation" ]), (PyDataSourceDetails, [ "SetLocation" ]), (PyDescriptionContent, None), @@ -354,6 +358,7 @@ def test_overloaded_missing(self): (PyGroupUser, None), (PyLabel, None), (PyProject, [ "Container", "SetLocation" ]), + (PyPublishableCustomView, [ "DisposeAsync", "File" ]), (PyPublishableDataSource, [ "DisposeAsync", "File", "SetLocation" ]), (PyPublishableGroup, [ "SetLocation" ]), (PyPublishableWorkbook, [ "ChildPermissionContentItems", "ChildType", "DisposeAsync", "File", "SetLocation", "ShouldMigrateChildPermissions" ]), @@ -365,6 +370,7 @@ def test_overloaded_missing(self): (PyWithDomain, None), (PyWithOwner, None), (PyWithTags, None), + (PyWithWorkbook, None), (PyWorkbook, [ "SetLocation" ]), (PyWorkbookDetails, [ "ChildPermissionContentItems", "ChildType", "SetLocation", "ShouldMigrateChildPermissions" ]), (PyCapability, None), @@ -409,12 +415,12 @@ def test_overloaded_missing(self): # endregion _test_class_data = [ - (PyMigrationPlanBuilder, None), - (PyServerToCloudMigrationPlanBuilder, None), + (PyMigrationPlanBuilder, [ "ForCustomPipeline", "ForCustomPipelineFactory" ]), + (PyServerToCloudMigrationPlanBuilder, [ "ForCustomPipeline", "ForCustomPipelineFactory" ]), (PyMigrationResult, None), (PyMigrationManifest, None), (PyMigrator, None), - (PyMigrationPlan, None), + (PyMigrationPlan, [ "PipelineFactoryOverride" ]), (PyMigrationHookBuilder, None), (PyContentTransformerBuilder, None), (PyContentMappingBuilder, None), diff --git a/src/Python/tests/test_migration_content.py b/src/Python/tests/test_migration_content.py index e4ab88a..10caabb 100644 --- a/src/Python/tests/test_migration_content.py +++ b/src/Python/tests/test_migration_content.py @@ -141,6 +141,7 @@ def test_setter_empty(self): IConnection, IConnectionsContent, IContainerContent, + ICustomView, IDataSource, IDataSourceDetails, IDescriptionContent, @@ -149,6 +150,7 @@ def test_setter_empty(self): IGroupUser, ILabel, IProject, + IPublishableCustomView, IPublishableDataSource, IPublishableGroup, IPublishableWorkbook, @@ -160,6 +162,7 @@ def test_setter_empty(self): IWithDomain, IWithOwner, IWithTags, + IWithWorkbook, IWorkbook, IWorkbookDetails ) @@ -168,6 +171,7 @@ def test_setter_empty(self): PyConnection, PyConnectionsContent, PyContainerContent, + PyCustomView, PyDataSource, PyDataSourceDetails, PyDescriptionContent, @@ -176,6 +180,7 @@ def test_setter_empty(self): PyGroupUser, PyLabel, PyProject, + PyPublishableCustomView, PyPublishableDataSource, PyPublishableGroup, PyPublishableWorkbook, @@ -187,6 +192,7 @@ def test_setter_empty(self): PyWithDomain, PyWithOwner, PyWithTags, + PyWithWorkbook, PyWorkbook, PyWorkbookDetails ) @@ -262,6 +268,56 @@ def test_container_getter(self): py = PyContainerContent(dotnet) assert py.container == None if dotnet.Container is None else PyContentReference(dotnet.Container) +class TestPyCustomViewGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + assert py._dotnet == dotnet + + def test_created_at_getter(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + assert py.created_at == dotnet.CreatedAt + + def test_updated_at_getter(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + assert py.updated_at == dotnet.UpdatedAt + + def test_last_accessed_at_getter(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + assert py.last_accessed_at == dotnet.LastAccessedAt + + def test_shared_getter(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + assert py.shared == dotnet.Shared + + def test_shared_setter(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + + # create test data + testValue = self.create(Boolean) + + # set property to new test value + py.shared = testValue + + # assert value + assert py.shared == testValue + + def test_base_view_id_getter(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + assert py.base_view_id == None if dotnet.BaseViewId is None else UUID(dotnet.BaseViewId.ToString()) + + def test_base_view_name_getter(self): + dotnet = self.create(ICustomView) + py = PyCustomView(dotnet) + assert py.base_view_name == dotnet.BaseViewName + class TestPyDataSourceGenerated(AutoFixtureTestBase): def test_ctor(self): @@ -524,6 +580,37 @@ def test_parent_project_getter(self): py = PyProject(dotnet) assert py.parent_project == None if dotnet.ParentProject is None else PyContentReference(dotnet.ParentProject) +class TestPyPublishableCustomViewGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(IPublishableCustomView) + py = PyPublishableCustomView(dotnet) + assert py._dotnet == dotnet + + def test_default_users_getter(self): + dotnet = self.create(IPublishableCustomView) + py = PyPublishableCustomView(dotnet) + assert len(dotnet.DefaultUsers) != 0 + assert len(py.default_users) == len(dotnet.DefaultUsers) + + def test_default_users_setter(self): + dotnet = self.create(IPublishableCustomView) + py = PyPublishableCustomView(dotnet) + assert len(dotnet.DefaultUsers) != 0 + assert len(py.default_users) == len(dotnet.DefaultUsers) + + # create test data + dotnetCollection = DotnetList[IContentReference]() + dotnetCollection.Add(self.create(IContentReference)) + dotnetCollection.Add(self.create(IContentReference)) + testCollection = [] if dotnetCollection is None else [PyContentReference(x) for x in dotnetCollection if x is not None] + + # set property to new test value + py.default_users = testCollection + + # assert value + assert len(py.default_users) == len(testCollection) + class TestPyPublishableGroupGenerated(AutoFixtureTestBase): def test_ctor(self): @@ -813,6 +900,31 @@ def test_tags_setter(self): # assert value assert len(py.tags) == len(testCollection) +class TestPyWithWorkbookGenerated(AutoFixtureTestBase): + + def test_ctor(self): + dotnet = self.create(IWithWorkbook) + py = PyWithWorkbook(dotnet) + assert py._dotnet == dotnet + + def test_workbook_getter(self): + dotnet = self.create(IWithWorkbook) + py = PyWithWorkbook(dotnet) + assert py.workbook == None if dotnet.Workbook is None else PyContentReference(dotnet.Workbook) + + def test_workbook_setter(self): + dotnet = self.create(IWithWorkbook) + py = PyWithWorkbook(dotnet) + + # create test data + testValue = self.create(IContentReference) + + # set property to new test value + py.workbook = None if testValue is None else PyContentReference(testValue) + + # assert value + assert py.workbook == None if testValue is None else PyContentReference(testValue) + class TestPyWorkbookGenerated(AutoFixtureTestBase): def test_ctor(self): diff --git a/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs b/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs index e9af5c0..46043b5 100644 --- a/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs +++ b/src/Tableau.Migration.PythonGenerator/PythonGenerationList.cs @@ -83,6 +83,9 @@ internal static class PythonGenerationList typeof(IPublishableGroup), typeof(IWithTags), typeof(IWithOwner), + typeof(IWithWorkbook), + typeof(ICustomView), + typeof(IPublishableCustomView), #endregion @@ -157,14 +160,14 @@ internal static class PythonGenerationList #endregion #region - Tableau.Migration.Content.Schedules - - + typeof(IInterval), typeof(IFrequencyDetails), typeof(ISchedule), typeof(ExtractRefreshContentType), typeof(IExtractRefreshTask<>), typeof(IWithSchedule<>), - + #endregion #region - Tableau.Migration.Content.Schedules.Server - diff --git a/src/Tableau.Migration.PythonGenerator/appsettings.json b/src/Tableau.Migration.PythonGenerator/appsettings.json index 44bffb6..f3e0605 100644 --- a/src/Tableau.Migration.PythonGenerator/appsettings.json +++ b/src/Tableau.Migration.PythonGenerator/appsettings.json @@ -4,6 +4,10 @@ { "namespace": "Tableau.Migration", "types": [ + { + "type": "ContentLocation", + "excludeMembers": [ "ForContentType" ] + }, { "type": "IResult", "excludeMembers": [ "CastFailure" ] diff --git a/src/Tableau.Migration/Api/ApiClient.cs b/src/Tableau.Migration/Api/ApiClient.cs index 390dcd7..1df5c8f 100644 --- a/src/Tableau.Migration/Api/ApiClient.cs +++ b/src/Tableau.Migration/Api/ApiClient.cs @@ -37,6 +37,7 @@ internal sealed class ApiClient : ApiClientBase, IApiClient private readonly IServerSessionProvider _sessionProvider; private readonly IHttpContentSerializer _contentSerializer; internal const string SITES_QUERY_NOT_SUPPORTED = "403069"; + internal const string EXPERIMENTAL_API_VERSION = "exp"; /// /// Creates a new object. diff --git a/src/Tableau.Migration/Api/ContentApiClientBase.cs b/src/Tableau.Migration/Api/ContentApiClientBase.cs index e8e76db..dc0fb27 100644 --- a/src/Tableau.Migration/Api/ContentApiClientBase.cs +++ b/src/Tableau.Migration/Api/ContentApiClientBase.cs @@ -15,14 +15,12 @@ // limitations under the License. // -using System; using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Tableau.Migration.Api.Rest; using Tableau.Migration.Api.Rest.Models; -using Tableau.Migration.Content; using Tableau.Migration.Content.Search; using Tableau.Migration.Net.Rest; using Tableau.Migration.Resources; @@ -33,10 +31,6 @@ internal abstract class ContentApiClientBase : ApiClientBase, IContentApiClient { protected readonly IContentReferenceFinderFactory ContentFinderFactory; - protected readonly Lazy> ProjectFinder; - - protected readonly Lazy> UserFinder; - public string UrlPrefix { get; } public ContentApiClientBase( @@ -50,22 +44,62 @@ public ContentApiClientBase( UrlPrefix = urlPrefix ?? RestUrlPrefixes.GetUrlPrefix(GetType()); ContentFinderFactory = finderFactory; - ProjectFinder = new(ContentFinderFactory.ForContentType); - UserFinder = new(ContentFinderFactory.ForContentType); } protected async Task FindProjectAsync( [NotNull] T? response, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) - where T : IWithProjectType, INamedContent - => await ContentFinderFactory.FindProjectAsync(response, Logger, SharedResourcesLocalizer, throwIfNotFound, cancel).ConfigureAwait(false); + where T : IWithProjectType, IRestIdentifiable + => await ContentFinderFactory + .FindProjectAsync( + response, + Logger, + SharedResourcesLocalizer, + throwIfNotFound, + cancel) + .ConfigureAwait(false); + + protected async Task FindUserAsync( + [NotNull] T? response, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + where T : IRestIdentifiable + => await ContentFinderFactory + .FindUserAsync( + response, + Logger, + SharedResourcesLocalizer, + throwIfNotFound, + cancel) + .ConfigureAwait(false); protected async Task FindOwnerAsync( [NotNull] T? response, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) - where T : IWithOwnerType, INamedContent - => await ContentFinderFactory.FindOwnerAsync(response, Logger, SharedResourcesLocalizer, throwIfNotFound, cancel).ConfigureAwait(false); + where T : IWithOwnerType, IRestIdentifiable + => await ContentFinderFactory + .FindOwnerAsync( + response, + Logger, + SharedResourcesLocalizer, + throwIfNotFound, + cancel) + .ConfigureAwait(false); + + protected async Task FindWorkbookAsync( + [NotNull] T? response, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + where T : IWithWorkbookReferenceType, IRestIdentifiable + => await ContentFinderFactory + .FindWorkbookAsync( + response, + Logger, + SharedResourcesLocalizer, + throwIfNotFound, + cancel) + .ConfigureAwait(false); } } diff --git a/src/Tableau.Migration/Api/CustomViewsApiClient.cs b/src/Tableau.Migration/Api/CustomViewsApiClient.cs new file mode 100644 index 0000000..c608dc6 --- /dev/null +++ b/src/Tableau.Migration/Api/CustomViewsApiClient.cs @@ -0,0 +1,352 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Publishing; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Config; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Files; +using Tableau.Migration.Content.Search; +using Tableau.Migration.Net; +using Tableau.Migration.Net.Rest; +using Tableau.Migration.Paging; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Api +{ + + internal sealed class CustomViewsApiClient : ContentApiClientBase, ICustomViewsApiClient + { + private readonly IHttpContentSerializer _serializer; + private readonly IConfigReader _configReader; + private readonly IContentFileStore _fileStore; + private readonly ICustomViewPublisher _customViewPublisher; + + public CustomViewsApiClient( + IRestRequestBuilderFactory restRequestBuilderFactory, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer sharedResourcesLocalizer, + IContentReferenceFinderFactory finderFactory, + IHttpContentSerializer serializer, + IConfigReader configReader, + IContentFileStore fileStore, + ICustomViewPublisher customViewPublisher) + : base( + restRequestBuilderFactory, + finderFactory, + loggerFactory, + sharedResourcesLocalizer, + RestUrlPrefixes.CustomViews) + { + _serializer = serializer; + _configReader = configReader; + _fileStore = fileStore; + _customViewPublisher = customViewPublisher; + } + + /// + public async Task> GetAllCustomViewsAsync(int pageNumber, int pageSize, CancellationToken cancel) + { + var getAllCustomViewsResult = await RestRequestBuilderFactory + .CreateUri($"{UrlPrefix}") + .WithPage(pageNumber, pageSize) + .ForGetRequest() + .SendAsync(cancel) + .ToPagedResultAsync(async (r, c) => + { + // Take all items. + var results = ImmutableArray.CreateBuilder(r.Items.Length); + + foreach (var item in r.Items) + { + var workbook = await FindWorkbookAsync(item, false, c).ConfigureAwait(false); + var owner = await FindOwnerAsync(item, false, c).ConfigureAwait(false); + + if (workbook is null || owner is null) + { + Logger.LogWarning( + SharedResourcesLocalizer[SharedResourceKeys.CustomViewSkippedMissingReferenceWarning], + item.Id, + item.Name, + item.Workbook!.Id, + workbook is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found], + item.Owner!.Id, + owner is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found]); + continue; + } + + results.Add(new CustomView(item, workbook, owner)); + } + + // Produce immutable list of type ICustomView and return. + return results.ToImmutable(); + }, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + + return getAllCustomViewsResult; + } + + /// + public async Task> UpdateCustomViewAsync( + Guid id, + CancellationToken cancel, + Guid? newOwnerId = null, + string? newViewName = null) + { + var result = await RestRequestBuilderFactory + .CreateUri($"{UrlPrefix}/{id.ToUrlSegment()}") + .ForPutRequest() + .WithXmlContent(new UpdateCustomViewRequest(newOwnerId, newViewName)) + .SendAsync(cancel) + .ToResultAsync(async (r, c) => + { + var workbook = await FindWorkbookAsync(r.Item!, true, c).ConfigureAwait(false); + var owner = await FindOwnerAsync(r.Item!, true, c).ConfigureAwait(false); + + return new CustomView(r.Item!, workbook, owner); + }, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + + return result; + } + + /// + public async Task DeleteCustomViewAsync(Guid id, CancellationToken cancel) + { + var result = await RestRequestBuilderFactory + .CreateUri($"{UrlPrefix}/{id.ToUrlSegment()}") + .ForDeleteRequest() + .SendAsync(cancel) + .ToResultAsync(_serializer, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + + return result; + } + + /// + public async Task>> GetAllCustomViewDefaultUsersAsync( + Guid id, + CancellationToken cancel) + { + IPager pager = new CustomViewDefaultUsersResponsePager( + this, + id, + _configReader.Get().BatchSize); + + return await pager.GetAllPagesAsync(cancel) + .ConfigureAwait(false); + } + + /// + public async Task> GetCustomViewDefaultUsersAsync( + Guid id, + int pageNumber, + int pageSize, + CancellationToken cancel) + { + var result = await RestRequestBuilderFactory + .CreateUri($"{UrlPrefix}/{id.ToUrlSegment()}/default/users") + .WithPage(pageNumber, pageSize) + .ForGetRequest() + .SendAsync(cancel) + .ToResultAsync(r => r, SharedResourcesLocalizer) + .ConfigureAwait(false); + + if (!result.Success) + { + return PagedResult.Failed( + result.Errors); + } + + // Take all items. + var results = ImmutableArray.CreateBuilder(result.Value.Items.Length); + + foreach (var item in result.Value.Items) + { + var user = await FindUserAsync(item, false, cancel).ConfigureAwait(false); + + if (user is null) + { + Logger.LogWarning( + SharedResourcesLocalizer[SharedResourceKeys.UserWithCustomViewDefaultSkippedMissingReferenceWarning], + item.Id, + id); + continue; + } + + results.Add(user); + } + + return PagedResult.Succeeded( + results.ToImmutable(), + result.Value.Pagination.PageNumber, + result.Value.Pagination.PageSize, + result.Value.Pagination.TotalAvailable, + ((IPageInfo)result.Value).FetchedAllPages); + } + + /// + public async Task>> SetCustomViewDefaultUsersAsync( + Guid id, + IEnumerable users, + CancellationToken cancel) + { + if (!users.Any()) + { + return Result> + .Succeeded(ImmutableArray.Empty); + } + + var setResult = await RestRequestBuilderFactory + .CreateUri($"{UrlPrefix}/{id.ToUrlSegment()}/default/users") + .ForPostRequest() + .WithXmlContent(new SetCustomViewDefaultUsersRequest(users)) + .SendAsync(cancel) + .ToResultAsync>((response) => + response.Items + .Select(item => (ICustomViewAsUserDefaultViewResult)new CustomViewAsUserDefaultViewResult(item)) + .ToImmutableArray(), + SharedResourcesLocalizer) + .ConfigureAwait(false); + + return setResult; + } + + /// + public async Task> DownloadCustomViewAsync( + Guid customViewId, + CancellationToken cancel) + { + var downloadResult = await RestRequestBuilderFactory + .CreateUri($"{UrlPrefix}/{customViewId.ToUrlSegment()}/{RestUrlPrefixes.Content}", true) + .ForGetRequest() + .DownloadAsync(cancel) + .ConfigureAwait(false); + + return downloadResult; + } + + #region - IPullApiClient Implementation - + /// + public async Task> PullAsync(ICustomView contentItem, CancellationToken cancel) + { + var downloadResult = await DownloadCustomViewAsync(contentItem.Id, cancel).ConfigureAwait(false); + + if (!downloadResult.Success) + { + return downloadResult.CastFailure(); + } + + var defaultUsersResult = await GetAllCustomViewDefaultUsersAsync(contentItem.Id, cancel).ConfigureAwait(false); + + if (!defaultUsersResult.Success) + { + return defaultUsersResult.CastFailure(); + } + + await using (downloadResult) + { + var file = await _fileStore.CreateAsync(contentItem, downloadResult.Value, cancel).ConfigureAwait(false); + + var result = new PublishableCustomView(contentItem, [.. defaultUsersResult.Value], file); + + return Result.Succeeded(result); + } + } + + #endregion + + /// + public async Task> PublishCustomViewAsync( + IPublishCustomViewOptions options, + CancellationToken cancel) + => await _customViewPublisher.PublishAsync(options, cancel).ConfigureAwait(false); + + #region - IPublishApiClient Implementation - + /// + public async Task> PublishAsync( + IPublishableCustomView contentItem, + CancellationToken cancel) + { + var fileStream = await contentItem.File.OpenReadAsync(cancel).ConfigureAwait(false); + await using (fileStream) + { + var publishResult = await PublishCustomViewAsync( + new PublishCustomViewOptions(contentItem, fileStream.Content), + cancel) + .ConfigureAwait(false); + + if (!publishResult.Success) + { + return publishResult.CastFailure(); + } + + return publishResult; + } + } + #endregion + + #region - IPagedListApiClient Implementation - + + /// + public IPager GetPager(int pageSize) => new ApiListPager(this, pageSize); + + #endregion + + #region - IApiPageAccessor Implementation - + + /// + public async Task> GetPageAsync(int pageNumber, int pageSize, CancellationToken cancel) + => await GetAllCustomViewsAsync(pageNumber, pageSize, cancel).ConfigureAwait(false); + + #endregion + + #region - IReadApiClient Implementation - + + /// + public async Task> GetByIdAsync(Guid contentId, CancellationToken cancel) + { + var getCustomViewResult = await RestRequestBuilderFactory + .CreateUri($"/{UrlPrefix}/{contentId.ToUrlSegment()}") + .ForGetRequest() + .SendAsync(cancel) + .ToResultAsync(async (r, c) => + { + var workbook = await FindWorkbookAsync(r.Item!, true, c).ConfigureAwait(false); + var owner = await FindOwnerAsync(r.Item!, true, c).ConfigureAwait(false); + + return new CustomView(r.Item!, workbook, owner); + }, SharedResourcesLocalizer, cancel) + .ConfigureAwait(false); + + return getCustomViewResult; + } + #endregion + } + +} diff --git a/src/Tableau.Migration/Api/DataSourcesApiClient.cs b/src/Tableau.Migration/Api/DataSourcesApiClient.cs index a581d12..ce62931 100644 --- a/src/Tableau.Migration/Api/DataSourcesApiClient.cs +++ b/src/Tableau.Migration/Api/DataSourcesApiClient.cs @@ -119,7 +119,17 @@ public async Task> GetAllPublishedDataSourcesAsync( var owner = await FindOwnerAsync(item, false, cancel).ConfigureAwait(false); if (project is null || owner is null) - continue; //Warnings will be logged by prior method calls. + { + Logger.LogWarning( + SharedResourcesLocalizer[SharedResourceKeys.DataSourceSkippedMissingReferenceWarning], + item.Id, + item.Name, + item.Project!.Id, + project is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found], + item.Owner!.Id, + owner is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found]); + continue; + } results.Add(new DataSource(item, project, owner)); } diff --git a/src/Tableau.Migration/Api/ICloudTasksApiClient.cs b/src/Tableau.Migration/Api/ICloudTasksApiClient.cs index c70ac3b..f963750 100644 --- a/src/Tableau.Migration/Api/ICloudTasksApiClient.cs +++ b/src/Tableau.Migration/Api/ICloudTasksApiClient.cs @@ -20,7 +20,6 @@ using System.Threading; using System.Threading.Tasks; using Tableau.Migration.Api.Models.Cloud; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; namespace Tableau.Migration.Api diff --git a/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs b/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs index 3060f58..5d3be6c 100644 --- a/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs +++ b/src/Tableau.Migration/Api/IContentReferenceFinderFactoryExtensions.cs @@ -24,7 +24,6 @@ using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Content; using Tableau.Migration.Content.Schedules; -using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Content.Search; using Tableau.Migration.Resources; @@ -39,26 +38,45 @@ internal static class IContentReferenceFinderFactoryExtensions ISharedResourcesLocalizer localizer, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) - where T : IWithProjectType, INamedContent - { - Guard.AgainstNull(response, nameof(response)); - - var project = Guard.AgainstNull(response.Project, () => nameof(response.Project)); - var projectId = Guard.AgainstDefaultValue(project.Id, () => nameof(response.Project.Id)); - - var projectFinder = finderFactory.ForContentType(); - - var foundProject = await projectFinder.FindByIdAsync(projectId, cancel).ConfigureAwait(false); - - if (foundProject is not null) - return foundProject; - - logger.LogWarning(localizer[SharedResourceKeys.ProjectReferenceNotFoundMessage], response.Project.Name, response.GetType().Name, response.Name); - - return throwIfNotFound - ? throw new InvalidOperationException($"The project with ID {projectId} was not found.") - : null; - } + where T : IWithProjectType, IRestIdentifiable + => await finderFactory + .FindAsync( + response, + r => + { + var project = Guard.AgainstNull(r.Project, () => nameof(response.Project)); + return Guard.AgainstDefaultValue(project.Id, () => nameof(response.Project.Id)); + }, + logger, + localizer, + throwIfNotFound, + SharedResourceKeys.ProjectReferenceNotFoundMessage, + SharedResourceKeys.ProjectReferenceNotFoundException, + cancel) + .ConfigureAwait(false); + + public static async Task FindUserAsync( + this IContentReferenceFinderFactory finderFactory, + [NotNull] T? response, + ILogger logger, + ISharedResourcesLocalizer localizer, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + where T : IRestIdentifiable + => await finderFactory + .FindAsync( + response, + r => + { + return Guard.AgainstDefaultValue(r.Id, () => nameof(response.Id)); + }, + logger, + localizer, + throwIfNotFound, + SharedResourceKeys.UserReferenceNotFoundMessage, + SharedResourceKeys.UserReferenceNotFoundException, + cancel) + .ConfigureAwait(false); public static async Task FindOwnerAsync( this IContentReferenceFinderFactory finderFactory, @@ -67,55 +85,98 @@ internal static class IContentReferenceFinderFactoryExtensions ISharedResourcesLocalizer localizer, [DoesNotReturnIf(true)] bool throwIfNotFound, CancellationToken cancel) - where T : IWithOwnerType, INamedContent - { - Guard.AgainstNull(response, nameof(response)); - - var owner = Guard.AgainstNull(response.Owner, () => nameof(response.Owner)); - var ownerId = Guard.AgainstDefaultValue(owner.Id, () => nameof(response.Owner.Id)); - - var userFinder = finderFactory.ForContentType(); - - var foundOwner = await userFinder.FindByIdAsync(ownerId, cancel).ConfigureAwait(false); + where T : IWithOwnerType, IRestIdentifiable + => await finderFactory + .FindAsync( + response, + r => + { + var owner = Guard.AgainstNull(r.Owner, () => nameof(response.Owner)); + return Guard.AgainstDefaultValue(owner.Id, () => nameof(response.Owner.Id)); + }, + logger, + localizer, + throwIfNotFound, + SharedResourceKeys.OwnerNotFoundMessage, + SharedResourceKeys.OwnerNotFoundException, + cancel) + .ConfigureAwait(false); + + public static async Task FindWorkbookAsync( + this IContentReferenceFinderFactory finderFactory, + [NotNull] T? response, + ILogger logger, + ISharedResourcesLocalizer localizer, + [DoesNotReturnIf(true)] bool throwIfNotFound, + CancellationToken cancel) + where T : IWithWorkbookReferenceType, IRestIdentifiable + => await finderFactory + .FindAsync( + response, + r => + { + var workbook = Guard.AgainstNull(r.Workbook, () => nameof(response.Workbook)); + return Guard.AgainstDefaultValue(workbook.Id, () => nameof(response.Workbook.Id)); + }, + logger, + localizer, + throwIfNotFound, + SharedResourceKeys.WorkbookReferenceNotFoundMessage, + SharedResourceKeys.WorkbookReferenceNotFoundException, + cancel) + .ConfigureAwait(false); - if (foundOwner is not null) - return foundOwner; + public static async Task FindExtractRefreshContentAsync( + this IContentReferenceFinderFactory finderFactory, + ExtractRefreshContentType contentType, + Guid contentId, + CancellationToken cancel) + { + var finder = finderFactory.ForExtractRefreshContent(contentType); - logger.LogWarning(localizer[SharedResourceKeys.OwnerNotFoundMessage], ownerId, response.GetType().Name, response.Name); + var content = await finder.FindByIdAsync(contentId, cancel).ConfigureAwait(false); - return throwIfNotFound - ? throw new InvalidOperationException($"The owner with ID {ownerId} was not found.") - : null; + return Guard.AgainstNull(content, nameof(content)); } - public static async Task FindScheduleAsync( + private static async Task FindAsync( this IContentReferenceFinderFactory finderFactory, - [NotNull] IWithScheduleReferenceType? response, + [NotNull] TResponse? response, + Func getResponseId, + ILogger logger, + ISharedResourcesLocalizer localizer, + [DoesNotReturnIf(true)] bool throwIfNotFound, + string warningMessageResource, + string exceptionMessageResource, CancellationToken cancel) + where TResponse : IRestIdentifiable + where TContent : class, IContentReference { Guard.AgainstNull(response, nameof(response)); - var schedule = Guard.AgainstNull(response.Schedule, () => nameof(response.Schedule)); - var scheduleId = Guard.AgainstDefaultValue(schedule.Id, () => nameof(response.Schedule.Id)); + var responseId = getResponseId(response); - var scheduleFinder = finderFactory.ForContentType(); + var finder = finderFactory.ForContentType(); - var foundSchedule = await scheduleFinder.FindByIdAsync(scheduleId, cancel).ConfigureAwait(false); + var foundContent = await finder.FindByIdAsync(responseId, cancel).ConfigureAwait(false); - return Guard.AgainstNull(foundSchedule, nameof(foundSchedule)); - } + if (foundContent is not null) + return foundContent; - public static async Task FindExtractRefreshContentAsync( - this IContentReferenceFinderFactory finderFactory, - ExtractRefreshContentType contentType, - Guid contentId, - CancellationToken cancel) - { - var finder = finderFactory.ForExtractRefreshContent(contentType); + var namedResponse = response as INamedContent; - var content = await finder.FindByIdAsync(contentId, cancel).ConfigureAwait(false); + logger.LogWarning( + localizer[warningMessageResource], + responseId, + response.GetType().Name, + namedResponse?.Name ?? string.Empty); - return Guard.AgainstNull(content, nameof(content)); + return throwIfNotFound + ? throw new InvalidOperationException( + string.Format( + localizer[exceptionMessageResource], + responseId)) + : null; } } } diff --git a/src/Tableau.Migration/Api/ICustomViewsApiClient.cs b/src/Tableau.Migration/Api/ICustomViewsApiClient.cs new file mode 100644 index 0000000..9fe899a --- /dev/null +++ b/src/Tableau.Migration/Api/ICustomViewsApiClient.cs @@ -0,0 +1,119 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Content; +using Tableau.Migration.Paging; + +namespace Tableau.Migration.Api +{ + /// + /// Interface for API client Custom View operations. + /// + public interface ICustomViewsApiClient : + IContentApiClient, + IPagedListApiClient, + IApiPageAccessor, + IReadApiClient, + IPullApiClient, + IPublishApiClient + { + /// + /// Gets all custom views in the current site. + /// + /// The 1-indexed page number. + /// The size of the page. + /// The cancellation token to obey. + /// A list of a page of custom views in the current site. + Task> GetAllCustomViewsAsync(int pageNumber, int pageSize, CancellationToken cancel); + + /// + /// Changes the owner of an existing custom view. + /// + /// The ID for the custom view. + /// The cancellation token to obey. + /// The new owner ID. + /// The new view name. + /// + Task> UpdateCustomViewAsync( + Guid id, + CancellationToken cancel, + Guid? newOwnerId = null, + string? newViewName = null); + + /// + /// Deletes the specified custom view. + /// + /// The ID for the custom view. + /// The cancellation token to obey. + /// + Task DeleteCustomViewAsync( + Guid id, + CancellationToken cancel); + + /// + /// Gets the list of user content references whose default view is the specified custom view. + /// + /// The ID for the custom view. + /// The cancellation token to obey. + /// A list of all users whose default view is the specified custom view. + Task>> GetAllCustomViewDefaultUsersAsync( + Guid id, + CancellationToken cancel); + + /// + /// Gets the paged list of user content references whose default view is the specified custom view. + /// + /// The ID for the custom view. + /// The 1-indexed page number. + /// The size of the page. + /// The cancellation token to obey. + /// A list of a page of users whose default view is the specified custom view. + Task> GetCustomViewDefaultUsersAsync( + Guid id, + int pageNumber, + int pageSize, + CancellationToken cancel); + + /// + /// Sets the specified custom for as the default view for up to 100 specified users. Success or failure for each user is reported in the response body. + /// + /// The ID for the custom view. + /// The list of users. + /// The cancellation token to obey. + /// + Task>> SetCustomViewDefaultUsersAsync( + Guid id, + IEnumerable users, + CancellationToken cancel); + + /// + /// Downloads the custom view. + /// + /// The ID for the custom view. + /// The cancellation token to obey. + /// + Task> DownloadCustomViewAsync( + Guid customViewId, + CancellationToken cancel); + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/IServerTasksApiClient.cs b/src/Tableau.Migration/Api/IServerTasksApiClient.cs index f486330..1b22789 100644 --- a/src/Tableau.Migration/Api/IServerTasksApiClient.cs +++ b/src/Tableau.Migration/Api/IServerTasksApiClient.cs @@ -18,7 +18,6 @@ using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; diff --git a/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs index a65b1c5..653851a 100644 --- a/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Api/IServiceCollectionExtensions.cs @@ -73,8 +73,10 @@ internal static IServiceCollection AddMigrationApiClient(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); //API Simulator. services.AddSingleton(); @@ -85,6 +87,7 @@ internal static IServiceCollection AddMigrationApiClient(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(typeof(ILabelsApiClient<>), typeof(LabelsApiClient<>)); diff --git a/src/Tableau.Migration/Api/ISitesApiClient.cs b/src/Tableau.Migration/Api/ISitesApiClient.cs index c3a7f30..b5c9594 100644 --- a/src/Tableau.Migration/Api/ISitesApiClient.cs +++ b/src/Tableau.Migration/Api/ISitesApiClient.cs @@ -84,6 +84,11 @@ public interface ISitesApiClient : IAsyncDisposable, IContentApiClient /// ICloudTasksApiClient CloudTasks { get; } + /// + /// Gets the API client for custom view operations. + /// + public ICustomViewsApiClient CustomViews { get; } + /// /// Gets the site with the specified ID. /// diff --git a/src/Tableau.Migration/Api/Models/CustomViewAsUserDefaultViewResult.cs b/src/Tableau.Migration/Api/Models/CustomViewAsUserDefaultViewResult.cs new file mode 100644 index 0000000..d786175 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/CustomViewAsUserDefaultViewResult.cs @@ -0,0 +1,41 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using Tableau.Migration.Api.Rest.Models.Responses; + +namespace Tableau.Migration.Api.Models +{ + class CustomViewAsUserDefaultViewResult : ICustomViewAsUserDefaultViewResult + { + /// + public Guid UserId { get; } + + /// + public bool Success { get; } + + public CustomViewAsUserDefaultViewResult(CustomViewAsUsersDefaultViewResponse.CustomViewAsUserDefaultViewType response) + { + var user = Guard.AgainstNull(response.User, () => response.User); + + UserId = Guard.AgainstDefaultValue(user.Id, () => response.User.Id); + + Success = response.Success; + } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Models/ICustomViewAsUserDefaultViewResult.cs b/src/Tableau.Migration/Api/Models/ICustomViewAsUserDefaultViewResult.cs new file mode 100644 index 0000000..454dad9 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/ICustomViewAsUserDefaultViewResult.cs @@ -0,0 +1,33 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Immutable; +using Tableau.Migration.Api.Rest; + +namespace Tableau.Migration.Api.Models +{ + /// + /// The custom view as user default view result item + /// + public interface ICustomViewAsUserDefaultViewResult + { + /// + /// Gets the success of set custom view as default for users + /// + bool Success { get; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Models/IPublishCustomViewOptions.cs b/src/Tableau.Migration/Api/Models/IPublishCustomViewOptions.cs new file mode 100644 index 0000000..ac3f75c --- /dev/null +++ b/src/Tableau.Migration/Api/Models/IPublishCustomViewOptions.cs @@ -0,0 +1,47 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Api.Models +{ + /// + /// Interface for API client Custom View publish options. + /// + public interface IPublishCustomViewOptions : IPublishFileOptions + { + /// + /// The name of the Custom View. + /// + string Name { get; set; } + + /// + /// Flag indicating if the Custom View is shared. + /// + bool Shared { get; set; } + + /// + /// The ID of the Custom View's workbook. + /// + Guid WorkbookId { get; set; } + + /// + /// The Owner ID for the Custom View. + /// + Guid OwnerId { get; set; } + } +} diff --git a/src/Tableau.Migration/Api/Models/PublishCustomViewOptions.cs b/src/Tableau.Migration/Api/Models/PublishCustomViewOptions.cs new file mode 100644 index 0000000..db1dc04 --- /dev/null +++ b/src/Tableau.Migration/Api/Models/PublishCustomViewOptions.cs @@ -0,0 +1,62 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.IO; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Api.Models +{ + + /// + public class PublishCustomViewOptions : PublishContentWithFileOptions, IPublishCustomViewOptions + { + /// + public string Name { get; set; } + + /// + public bool Shared { get; set; } + + /// + public Guid WorkbookId { get; set; } + + /// + public Guid OwnerId { get; set; } + + /// + /// Creates a new instance. + /// + /// The publishable custom view information. + /// The custom view file as a . + /// The type of custom view file. Defaults to . + public PublishCustomViewOptions( + IPublishableCustomView customView, + Stream file, + string fileType = CustomViewFileTypes.Json) + : base( + file, + customView.File.OriginalFileName, + fileType) + { + Name = customView.Name; + Shared = customView.Shared; + WorkbookId = customView.Workbook.Id; + OwnerId = customView.Owner.Id; + } + } +} diff --git a/src/Tableau.Migration/Api/Models/PublishDataSourceOptions.cs b/src/Tableau.Migration/Api/Models/PublishDataSourceOptions.cs index eb7f306..0426b25 100644 --- a/src/Tableau.Migration/Api/Models/PublishDataSourceOptions.cs +++ b/src/Tableau.Migration/Api/Models/PublishDataSourceOptions.cs @@ -25,7 +25,7 @@ namespace Tableau.Migration.Api.Models /// /// Class for API client data source publish options. /// - public class PublishDataSourceOptions : IPublishDataSourceOptions + public class PublishDataSourceOptions : PublishContentWithFileOptions, IPublishDataSourceOptions { /// public string Name { get; } @@ -45,31 +45,26 @@ public class PublishDataSourceOptions : IPublishDataSourceOptions /// public Guid ProjectId { get; } - /// - public Stream File { get; } - - /// - public string FileName { get; } - - /// - public string FileType { get; } - /// /// Creates a new instance. /// /// The publishable data source information. /// The data source file as a /// The type of data source file. - public PublishDataSourceOptions(IPublishableDataSource dataSource, Stream file, string fileType = DataSourceFileTypes.Tdsx) + public PublishDataSourceOptions( + IPublishableDataSource dataSource, + Stream file, + string fileType = DataSourceFileTypes.Tdsx) + : base( + file, + dataSource.File.OriginalFileName, + fileType) { Name = dataSource.Name; Description = dataSource.Description; UseRemoteQueryAgent = dataSource.UseRemoteQueryAgent; EncryptExtracts = dataSource.EncryptExtracts; ProjectId = ((IContainerContent)dataSource).Container.Id; - File = file; - FileName = dataSource.File.OriginalFileName; - FileType = fileType; } } } diff --git a/src/Tableau.Migration/Api/Models/PublishFlowOptions.cs b/src/Tableau.Migration/Api/Models/PublishFlowOptions.cs index a5ad239..5c1384e 100644 --- a/src/Tableau.Migration/Api/Models/PublishFlowOptions.cs +++ b/src/Tableau.Migration/Api/Models/PublishFlowOptions.cs @@ -25,7 +25,7 @@ namespace Tableau.Migration.Api.Models /// /// Class for API client prep flow publish options. /// - public class PublishFlowOptions : IPublishFlowOptions + public class PublishFlowOptions : PublishContentWithFileOptions, IPublishFlowOptions { /// public string Name { get; } @@ -39,29 +39,24 @@ public class PublishFlowOptions : IPublishFlowOptions /// public Guid ProjectId { get; } - /// - public Stream File { get; } - - /// - public string FileName { get; } - - /// - public string FileType { get; } - /// /// Creates a new instance. /// /// The publishable prep flow information. /// The prep flow file as a /// The type of prep flow file. - public PublishFlowOptions(IPublishableFlow flow, Stream file, string fileType = FlowFileTypes.Tflx) + public PublishFlowOptions( + IPublishableFlow flow, + Stream file, + string fileType = FlowFileTypes.Tflx) + : base( + file, + flow.File.OriginalFileName, + fileType) { Name = flow.Name; Description = flow.Description; ProjectId = ((IContainerContent)flow).Container.Id; - File = file; - FileName = flow.File.OriginalFileName; - FileType = fileType; } } } diff --git a/src/Tableau.Migration/Api/Models/PublishWorkbookOptions.cs b/src/Tableau.Migration/Api/Models/PublishWorkbookOptions.cs index c3ce46f..1e7b164 100644 --- a/src/Tableau.Migration/Api/Models/PublishWorkbookOptions.cs +++ b/src/Tableau.Migration/Api/Models/PublishWorkbookOptions.cs @@ -26,7 +26,7 @@ namespace Tableau.Migration.Api.Models /// /// Class for API client workbook publish options. /// - public class PublishWorkbookOptions : IPublishWorkbookOptions + public class PublishWorkbookOptions : PublishContentWithFileOptions, IPublishWorkbookOptions { /// public string Name { get; } @@ -52,15 +52,6 @@ public class PublishWorkbookOptions : IPublishWorkbookOptions /// public Guid ProjectId { get; } - /// - public Stream File { get; } - - /// - public string FileName { get; } - - /// - public string FileType { get; } - /// public IEnumerable HiddenViewNames { get; } @@ -70,17 +61,21 @@ public class PublishWorkbookOptions : IPublishWorkbookOptions /// The publishable workbook information. /// The workbook file as a /// The type of workbook file. - public PublishWorkbookOptions(IPublishableWorkbook workbook, Stream file, string fileType = WorkbookFileTypes.Twbx) + public PublishWorkbookOptions( + IPublishableWorkbook workbook, + Stream file, + string fileType = WorkbookFileTypes.Twbx) + : base( + file, + workbook.File.OriginalFileName, + fileType) { Name = workbook.Name; Description = workbook.Description; ShowTabs = workbook.ShowTabs; EncryptExtracts = workbook.EncryptExtracts; ThumbnailsUserId = workbook.ThumbnailsUserId; - ProjectId = ((IContainerContent)workbook).Container.Id; - File = file; - FileName = workbook.File.OriginalFileName; - FileType = fileType; + ProjectId = ((IContainerContent)workbook).Container.Id; HiddenViewNames = workbook.HiddenViewNames; } } diff --git a/src/Tableau.Migration/Api/ProjectsApiClient.cs b/src/Tableau.Migration/Api/ProjectsApiClient.cs index b6f3d3c..d7debec 100644 --- a/src/Tableau.Migration/Api/ProjectsApiClient.cs +++ b/src/Tableau.Migration/Api/ProjectsApiClient.cs @@ -78,7 +78,7 @@ public async Task> CreateProjectAsync(ICreateProjectOptions op .ToResultAsync(async (r, c) => { var response = Guard.AgainstNull(r.Item, () => r.Item); - var project = await RestProjectBuilder.BuildProjectAsync(response, options.ParentProject, UserFinder.Value, c) + var project = await RestProjectBuilder.BuildProjectAsync(response, options.ParentProject, ContentFinderFactory.ForContentType(), c) .ConfigureAwait(false); return project; }, SharedResourcesLocalizer, cancel) @@ -165,7 +165,7 @@ public async Task DeleteProjectAsync(Guid projectId, CancellationToken #region - IListApiClient Implementation - public IPager GetPager(int pageSize) - => new BreadthFirstPathHierarchyPager(new RestProjectBuilderPager(this, UserFinder.Value, pageSize), pageSize); + => new BreadthFirstPathHierarchyPager(new RestProjectBuilderPager(this, ContentFinderFactory.ForContentType(), pageSize), pageSize); #endregion @@ -213,7 +213,7 @@ public async Task> PublishAsync(IProject item, CancellationTok // The owner may be different, though, which we'll want to know for the post-publish hook. var existingProject = await RestProjectBuilder.BuildProjectAsync(existingProjectResult.Value[0], - item.ParentProject, UserFinder.Value, cancel).ConfigureAwait(false); + item.ParentProject, ContentFinderFactory.ForContentType(), cancel).ConfigureAwait(false); return Result.Succeeded(existingProject); } diff --git a/src/Tableau.Migration/Api/PublishContentWithFileOptions.cs b/src/Tableau.Migration/Api/PublishContentWithFileOptions.cs new file mode 100644 index 0000000..1d87bdd --- /dev/null +++ b/src/Tableau.Migration/Api/PublishContentWithFileOptions.cs @@ -0,0 +1,74 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.IO; +using Tableau.Migration.Api; + +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Rest.Models.Types; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Api +{ + /// + /// + /// + public abstract class PublishContentWithFileOptions : IPublishFileOptions + { + /// + /// Constructor to build from File Content and File. + /// + /// + /// + /// + public PublishContentWithFileOptions( + Stream file, + string fileName, + string fileType) + { + File = file; + FileName = fileName; + FileType = fileType; + } + /// + public Stream File { get; set; } + + /// + public string FileName { get; set; } + + /// + public string FileType { get; set; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Publishing/CustomViewPublisher.cs b/src/Tableau.Migration/Api/Publishing/CustomViewPublisher.cs new file mode 100644 index 0000000..58ca642 --- /dev/null +++ b/src/Tableau.Migration/Api/Publishing/CustomViewPublisher.cs @@ -0,0 +1,86 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Models; +using Tableau.Migration.Api.Publishing; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Content.Search; +using Tableau.Migration.Net; +using Tableau.Migration.Net.Rest; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Api +{ + internal class CustomViewPublisher : + FilePublisherBase, + ICustomViewPublisher + { + public CustomViewPublisher( + IRestRequestBuilderFactory restRequestBuilderFactory, + IContentReferenceFinderFactory finderFactory, + IServerSessionProvider sessionProvider, + ILoggerFactory loggerFactory, + ISharedResourcesLocalizer sharedResourcesLocalizer, + IHttpStreamProcessor httpStreamProcessor) + : base( + restRequestBuilderFactory, + finderFactory, + sessionProvider, + loggerFactory, + sharedResourcesLocalizer, + httpStreamProcessor, + RestUrlPrefixes.CustomViews) + { } + + protected override CommitCustomViewPublishRequest BuildCommitRequest(IPublishCustomViewOptions options) + => new(options); + + protected async override Task> SendCommitRequestAsync( + IPublishCustomViewOptions options, + string uploadSessionId, + MultipartContent content, + CancellationToken cancel) + { + var sendResult = await RestRequestBuilderFactory + .CreateUri(ContentTypeUrlPrefix, useExperimental: true) + .WithQuery("uploadSessionId", uploadSessionId) + .ForPostRequest() + .WithContent(content) + .SendAsync(cancel) + .ConfigureAwait(false); + + var result = sendResult + .ToResult(r => + { + var workbook = new ContentReferenceStub(r.Item!.Workbook!.Id, string.Empty, new ContentLocation()); + var owner = new ContentReferenceStub(r.Item!.Owner!.Id, string.Empty, new ContentLocation()); + + return new CustomView(r.Item, workbook, owner); + }, + SharedResourcesLocalizer); + + return result; + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IOwnerType.cs b/src/Tableau.Migration/Api/Publishing/ICustomViewPublisher.cs similarity index 72% rename from src/Tableau.Migration/Api/Rest/Models/IOwnerType.cs rename to src/Tableau.Migration/Api/Publishing/ICustomViewPublisher.cs index 4647a86..3346611 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IOwnerType.cs +++ b/src/Tableau.Migration/Api/Publishing/ICustomViewPublisher.cs @@ -15,11 +15,14 @@ // limitations under the License. // -namespace Tableau.Migration.Api.Rest.Models +using Tableau.Migration.Api.Models; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Api.Publishing { /// - /// Interface representing an XML element for the owner of a content item. + /// Interface for Custom View publisher classes. /// - public interface IOwnerType : IRestIdentifiable + public interface ICustomViewPublisher : IFilePublisher { } } diff --git a/src/Tableau.Migration/Api/Rest/Models/CustomViewDefaultUsersResponsePager.cs b/src/Tableau.Migration/Api/Rest/Models/CustomViewDefaultUsersResponsePager.cs new file mode 100644 index 0000000..d59e824 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/CustomViewDefaultUsersResponsePager.cs @@ -0,0 +1,56 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Paging; + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Pager implementation that pages users with a custom view as the default view. + /// + internal sealed class CustomViewDefaultUsersResponsePager + : IndexedPagerBase, IPager + { + private readonly ICustomViewsApiClient _apiClient; + private readonly Guid _customViewId; + + public CustomViewDefaultUsersResponsePager( + ICustomViewsApiClient apiClient, + Guid customViewId, + int pageSize) + : base(pageSize) + { + _apiClient = apiClient; + _customViewId = customViewId; + } + + protected override async Task> GetPageAsync( + int pageNumber, + int pageSize, + CancellationToken cancel) + => await _apiClient + .GetCustomViewDefaultUsersAsync( + _customViewId, + pageNumber, + pageSize, + cancel) + .ConfigureAwait(false); + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/ICustomViewType.cs b/src/Tableau.Migration/Api/Rest/Models/ICustomViewType.cs new file mode 100644 index 0000000..c0ab741 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/ICustomViewType.cs @@ -0,0 +1,61 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Api.Rest.Models +{ + /// + /// Interface for a custom view REST response. + /// + public interface ICustomViewType : + IRestIdentifiable, + INamedContent, + IWithWorkbookReferenceType, + IWithOwnerType + { + /// + /// The created timestamp for the response. + /// + public string? CreatedAt { get; } + + /// + /// The updated timestamp for the response. + /// + public string? UpdatedAt { get; } + + /// + /// The lastAccessed timestamp for the response. + /// + public string? LastAccessedAt { get; } + + /// + /// The shared flag for the response. + /// + public bool Shared { get; } + + /// + /// The view ID for the response. + /// + public Guid? ViewId { get; } + + /// + /// The view name for the response. + /// + public string? ViewName { get; } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/IWithOwnerType.cs b/src/Tableau.Migration/Api/Rest/Models/IWithOwnerType.cs index fd79564..3239527 100644 --- a/src/Tableau.Migration/Api/Rest/Models/IWithOwnerType.cs +++ b/src/Tableau.Migration/Api/Rest/Models/IWithOwnerType.cs @@ -25,6 +25,6 @@ public interface IWithOwnerType /// /// Gets the owner for the response. /// - IOwnerType? Owner { get; } + IRestIdentifiable? Owner { get; } } } diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/CommitCustomViewPublishRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/CommitCustomViewPublishRequest.cs new file mode 100644 index 0000000..b731d55 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/CommitCustomViewPublishRequest.cs @@ -0,0 +1,150 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; +using Tableau.Migration.Api.Models; + +namespace Tableau.Migration.Api.Rest.Models.Requests +{ + /// + /// Class representing an commit Custom View request. + /// + [XmlType(XmlTypeName)] + public class CommitCustomViewPublishRequest : TableauServerRequest + { + /// + /// The default parameterless constructor. + /// + public CommitCustomViewPublishRequest() + { } + + /// + /// The constructor to build from . + /// + /// The Custom View publish options. + public CommitCustomViewPublishRequest(IPublishCustomViewOptions options) + { + CustomView = new CustomViewType(options); + } + + /// + /// The Custom View. + /// + [XmlElement("customView")] + public CustomViewType? CustomView { get; set; } + + /// + /// The Custom View type in the API request body. + /// + public class CustomViewType + { + /// + /// The default parameterless constructor. + /// + public CustomViewType() + { } + + /// + /// The constructor to build from + /// + public CustomViewType(IPublishCustomViewOptions options) + { + Name = options.Name; + Shared = options.Shared; + Workbook = new WorkbookType(options.WorkbookId); + Owner = new OwnerType(options.OwnerId); + } + + /// + /// The Custom View name. + /// + [XmlAttribute(AttributeName = "name")] + public string? Name { get; set; } + + /// + /// Flag to indicate if the Custom View is shared. + /// + [XmlAttribute(AttributeName = "shared")] + public bool Shared { get; set; } + + /// + /// The workbook to which this Custom View is linked. + /// + [XmlElement("workbook")] + public WorkbookType? Workbook { get; set; } + /// + /// The ID of the owner for this Custom View. + /// + [XmlElement("owner")] + public OwnerType? Owner { get; set; } + + + /// + /// The workbook type in the API request body. + /// + public class WorkbookType + { + /// + /// The default parameterless constructor. + /// + public WorkbookType() + { } + + /// + /// The constructor to build from the ID. + /// + /// The workbook ID. + public WorkbookType(Guid id) + { + Id = id; + } + /// + /// The workbook Id. + /// + [XmlAttribute(AttributeName = "id")] + public Guid Id { get; set; } + } + + /// + /// The owner type in the API request body. + /// + public class OwnerType + { + /// + /// The default parameterless constructor. + /// + public OwnerType() + { } + + /// + /// The constructor to build from the ID. + /// + /// The owner ID. + public OwnerType(Guid id) + { + Id = id; + } + /// + /// The Owner Id. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs index 9c62fa3..71d2629 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/CommitWorkbookPublishRequest.cs @@ -125,7 +125,7 @@ public class WorkbookType /// Gets or sets the views to hide or show in the request /// [XmlArray("views")] - [XmlArrayItem("views")] + [XmlArrayItem("view")] public ViewType[] Views { get; set; } = Array.Empty(); #region - Object Specific Types - diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/SetCustomViewDefaultUsersRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/SetCustomViewDefaultUsersRequest.cs new file mode 100644 index 0000000..45fbe7c --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/SetCustomViewDefaultUsersRequest.cs @@ -0,0 +1,89 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Xml.Serialization; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Api.Rest.Models.Requests +{ + /// + /// + /// Class representing a set custom view as default for users request. + /// + /// + /// See Tableau API Reference + /// + /// documentation for details. + /// + /// + [XmlType(XmlTypeName)] + public class SetCustomViewDefaultUsersRequest : TableauServerRequest + { + /// + /// Creates a new instance. + /// + public SetCustomViewDefaultUsersRequest() + { } + + /// + /// Creates a new instance. + /// + public SetCustomViewDefaultUsersRequest(IEnumerable users) + { + Users = users.Select(user => new UserType(user)).ToArray(); + } + + /// + /// Gets or sets the user for the request. + /// + [XmlArray("users")] + [XmlArrayItem("user")] + public UserType[] Users { get; set; } = Array.Empty(); + + /// + /// The user type in the API request body. + /// + public class UserType + { + /// + /// Gets or sets the id for the request. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Constructor to build from + /// + /// The object. + public UserType(IContentReference user) + { + Id = user.Id; + } + + /// + /// Constructor to build from + /// + public UserType() + { } + } + + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Api/Rest/Models/Requests/UpdateCustomViewRequest.cs b/src/Tableau.Migration/Api/Rest/Models/Requests/UpdateCustomViewRequest.cs new file mode 100644 index 0000000..c98ca28 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Requests/UpdateCustomViewRequest.cs @@ -0,0 +1,108 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Requests +{ + /// + /// + /// Class representing an update custom view request. + /// + /// + /// See Tableau API Reference for documentation. + /// + /// + [XmlType(XmlTypeName)] + public class UpdateCustomViewRequest : TableauServerRequest + { + /// + /// The default parameterless constructor. + /// + public UpdateCustomViewRequest() { } + + /// + /// Builds the update request for a custom view. + /// + /// (Optional) The new owner ID for the custom view. + /// (Optional) The new name for the custom view. + public UpdateCustomViewRequest( + Guid? newOwnerId = null, + string? newName = null) + { + if (newOwnerId is not null && + newOwnerId != Guid.Empty) + { + CustomView.Owner.Id = newOwnerId.Value; + } + + if (!string.IsNullOrWhiteSpace(newName)) + { + CustomView.Name = newName; + } + } + + /// + /// Gets or sets the custom view for the request. + /// + [XmlElement("customView")] + public CustomViewType CustomView { get; set; } = new CustomViewType(); + + /// + /// The custom view type in the API request body. + /// + public class CustomViewType + { + /// + /// Gets or sets the name for the request. + /// + [XmlAttribute("name")] + public string? Name { get; set; } = null; + + /// + /// Gets or sets the owner for the request. + /// + [XmlElement("owner")] + public OwnerType Owner { get; set; } = new OwnerType(); + + /// + /// The custom view type in the API request body. + /// + public class OwnerType + { + private Guid? _id; + + /// + /// Gets or sets the ID for the request. + /// + [XmlAttribute("id")] + public Guid Id + { + get => _id!.Value; + set => _id = value; + } + + /// + /// Defines the serialization for the property . + /// + [XmlIgnore] + public bool IdSpecified => _id.HasValue; + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/CreateProjectResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/CreateProjectResponse.cs index 99061f9..b671f5d 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/CreateProjectResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/CreateProjectResponse.cs @@ -93,14 +93,14 @@ public class ProjectType : IProjectType public OwnerType? Owner { get; set; } /// - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; #region - Object Specific Types - /// /// Class representing a REST API user response. /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewAsUsersDefaultViewResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewAsUsersDefaultViewResponse.cs new file mode 100644 index 0000000..9e89c6c --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewAsUsersDefaultViewResponse.cs @@ -0,0 +1,72 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// Class representing a Set Custom View as Default for Users response. + /// + [XmlType(XmlTypeName)] + public class CustomViewAsUsersDefaultViewResponse : TableauServerListResponse + { + /// + /// Gets or sets the groups for the response. + /// + [XmlArray("customViewAsUserDefaultResults")] + [XmlArrayItem("customViewAsUserDefaultViewResult")] + public override CustomViewAsUserDefaultViewType[] Items { get; set; } = Array.Empty(); + + /// + /// Class representing a site response. + /// + public class CustomViewAsUserDefaultViewType + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("success")] + public bool Success { get; set; } + + /// + /// Gets or sets the error for the response. + /// + [XmlElement("error")] + public Error? Error { get; set; } + + /// + /// Gets or sets the user for the response. + /// + [XmlElement("user")] + public UserType? User { get; set; } + + /// + /// Class representing a REST API user response. + /// + public class UserType + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewResponse.cs new file mode 100644 index 0000000..5e007ff --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewResponse.cs @@ -0,0 +1,191 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// Class representing a custom view response. + /// See Tableau API Reference for documentation. + /// + [XmlType(XmlTypeName)] + public class CustomViewResponse : TableauServerResponse + { + /// + /// Gets or sets the custom view for the response. + /// + [XmlElement("customView")] + public override CustomViewType? Item { get; set; } + + /// + /// Class representing a custom view on the response. + /// + public class CustomViewType : ICustomViewType + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the createdAt timestamp for the response. + /// + [XmlAttribute("createdAt")] + public string? CreatedAt { get; set; } + + /// + /// Gets or sets the updatedAt timestamp for the response. + /// + [XmlAttribute("updatedAt")] + public string? UpdatedAt { get; set; } + + /// + /// Gets or sets the lastAccessedAt timestamp for the response. + /// + [XmlAttribute("lastAccessedAt")] + public string? LastAccessedAt { get; set; } + + /// + /// Gets or sets the shared flag for the response. + /// + [XmlAttribute("shared")] + public bool Shared { get; set; } + + /// + /// Gets or sets the view for the response. + /// + [XmlElement("view")] + public ViewType? View { get; set; } + + Guid? ICustomViewType.ViewId => View?.Id; + + string? ICustomViewType.ViewName => View?.Name; + + /// + /// Gets or sets the workbook for the response. + /// + [XmlElement("workbook")] + public WorkbookType? Workbook { get; set; } + + IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; + + /// + /// Gets or sets the owner for the response. + /// + [XmlElement("owner")] + public OwnerType? Owner { get; set; } + + IRestIdentifiable? IWithOwnerType.Owner => Owner; + + /// + /// Class representing a REST API view on the response. + /// + public class ViewType : IRestIdentifiable + { + /// + /// The default parameterless constructor. + /// + public ViewType() + { } + + /// + /// The constructor to build from ID and Name. + /// + /// The ID of the base view. + /// The name of the base view. + public ViewType(Guid id, string name) + { + Id = id; + Name = name; + } + + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + + /// + /// Class representing a REST API workbook on the response. + /// + public class WorkbookType : IRestIdentifiable + { + /// + /// The default parameterless constructor. + /// + public WorkbookType() + { } + + /// + /// + /// + /// + public WorkbookType(WorkbookResponse.WorkbookType workbook) + { + Id = workbook.Id; + Name = workbook.Name; + } + + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + + /// + /// Class representing a REST API owner on the response. + /// + public class OwnerType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs index e7d45f0..906dbb5 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/CustomViewsResponse.cs @@ -22,24 +22,62 @@ namespace Tableau.Migration.Api.Rest.Models.Responses { /// /// Class representing a custom views response. - /// - /// This is incomplete, there are more attributes than we're saving + /// See Tableau API Reference for documentation. /// [XmlType(XmlTypeName)] public class CustomViewsResponse : PagedTableauServerResponse { /// - /// Gets or sets the groups for the response. + /// Gets or sets the custom views for the response. /// [XmlArray("customViews")] [XmlArrayItem("customView")] public override CustomViewResponseType[] Items { get; set; } = Array.Empty(); /// - /// Class representing a site response. + /// Class representing a custom view on the response. /// - public class CustomViewResponseType + public class CustomViewResponseType : ICustomViewType { + /// + /// The default parameterless constructor. + /// + public CustomViewResponseType() { } + + /// + /// + /// + /// + public CustomViewResponseType(ICustomViewType customView) + { + Id = customView.Id; + Name = customView.Name; + CreatedAt = customView.CreatedAt; + UpdatedAt = customView.UpdatedAt; + LastAccessedAt = customView.LastAccessedAt; + Shared = customView.Shared; + View = new ViewType() + { + Id = Guard.AgainstNull(customView.ViewId, () => customView.ViewId), + Name = customView.ViewName + }; + + Guard.AgainstNull(customView.Workbook, () => customView.Workbook); + + Workbook = new WorkbookType() + { + Id = customView.Workbook.Id + }; + + Guard.AgainstNull(customView.Owner, () => customView.Owner); + Owner = new OwnerType() + { + Id = customView.Owner.Id + }; + + } + + /// /// Gets or sets the ID for the response. /// @@ -53,28 +91,38 @@ public class CustomViewResponseType public string? Name { get; set; } /// - /// Gets or sets the created timestamp for the response. + /// Gets or sets the createdAt timestamp for the response. /// [XmlAttribute("createdAt")] public string? CreatedAt { get; set; } /// - /// Gets or sets the updated timestamp for the response. + /// Gets or sets the updatedAt timestamp for the response. /// [XmlAttribute("updatedAt")] public string? UpdatedAt { get; set; } /// - /// Gets or sets shared flag for the response. + /// Gets or sets the lastAccessedAt timestamp for the response. + /// + [XmlAttribute("lastAccessedAt")] + public string? LastAccessedAt { get; set; } + + /// + /// Gets or sets the shared flag for the response. /// [XmlAttribute("shared")] public bool Shared { get; set; } /// - /// Gets or sets the owner for the response. + /// Gets or sets the view for the response. /// [XmlElement("view")] - public ViewType? view { get; set; } + public ViewType? View { get; set; } + + Guid? ICustomViewType.ViewId => View?.Id; + + string? ICustomViewType.ViewName => View?.Name; /// /// Gets or sets the workbook for the response. @@ -82,16 +130,20 @@ public class CustomViewResponseType [XmlElement("workbook")] public WorkbookType? Workbook { get; set; } + IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; + /// /// Gets or sets the owner for the response. /// [XmlElement("owner")] - public UserType? Owner { get; set; } + public OwnerType? Owner { get; set; } + + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// - /// Class representing a REST API user response. + /// Class representing a REST API view on the response. /// - public class ViewType + public class ViewType : IRestIdentifiable { /// /// Gets or sets the ID for the response. @@ -107,9 +159,9 @@ public class ViewType } /// - /// Class representing a REST API user response. + /// Class representing a REST API workbook on the response. /// - public class WorkbookType + public class WorkbookType : IRestIdentifiable { /// /// Gets or sets the ID for the response. @@ -125,10 +177,16 @@ public class WorkbookType } /// - /// Class representing a REST API user response. + /// Class representing a REST API owner on the response. /// - public class UserType + public class OwnerType : IRestIdentifiable { + /// + /// The default parameterless constructor. + /// + public OwnerType() + { } + /// /// Gets or sets the ID for the response. /// diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs index a2cc292..e16f304 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourceResponse.cs @@ -167,7 +167,7 @@ internal DataSourceType(IDataSourceDetailsType response) [XmlElement("owner")] public OwnerType? Owner { get; set; } - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// /// Gets or sets the data source tags for the response. @@ -230,7 +230,7 @@ public ProjectType(IProjectType project) } /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// [XmlAttribute("id")] @@ -246,7 +246,7 @@ public OwnerType() /// Constructor to build from /// /// - public OwnerType(IOwnerType owner) + public OwnerType(IRestIdentifiable owner) { Id = owner.Id; } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs index 1ae4583..70c49ea 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/DataSourcesResponse.cs @@ -121,7 +121,7 @@ public class DataSourceType : IDataSourceType [XmlElement("owner")] public OwnerType? Owner { get; set; } - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// /// Gets or sets the data source tags for the response. @@ -174,7 +174,7 @@ public ProjectType(IProjectReferenceType project) } /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// [XmlAttribute("id")] @@ -187,10 +187,10 @@ public OwnerType() { } /// - /// Constructor to build from + /// Constructor to build from /// /// - public OwnerType(IOwnerType owner) + public OwnerType(IRestIdentifiable owner) { Id = owner.Id; } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs index b9ab830..a0f3dd8 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowResponse.cs @@ -95,7 +95,7 @@ public class FlowType : IFlowType [XmlElement("owner")] public OwnerType? Owner { get; set; } - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// /// Gets or sets the tags for the response. @@ -131,7 +131,7 @@ public class ProjectType : IProjectReferenceType /// /// Class representing a REST API user response. /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs index 5f01a39..848189c 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/FlowsResponse.cs @@ -97,7 +97,7 @@ public class FlowType : IFlowType [XmlElement("owner")] public OwnerType? Owner { get; set; } - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// /// Gets or sets the tags for the response. @@ -133,7 +133,7 @@ public class ProjectType : IProjectReferenceType /// /// Class representing a REST API user response. /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs index caa9a79..720637a 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/ProjectsResponse.cs @@ -115,7 +115,7 @@ public class ProjectType : IProjectType public UserType? Owner { get; set; } /// - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// /// Gets or sets the contentCounts for the response. @@ -128,7 +128,7 @@ public class ProjectType : IProjectType /// /// Class representing a REST API user response. /// - public class UserType : IOwnerType + public class UserType : IRestIdentifiable { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateCustomViewResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateCustomViewResponse.cs new file mode 100644 index 0000000..a657245 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateCustomViewResponse.cs @@ -0,0 +1,157 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// Class representing a custom view update response. + /// + [XmlType(XmlTypeName)] + public class UpdateCustomViewResponse : TableauServerResponse + { + /// + /// Gets or sets the custom view for the response. + /// + [XmlElement("customView")] + public override CustomViewResponseType? Item { get; set; } + + /// + /// Class representing a custom view on the response. + /// + public class CustomViewResponseType : ICustomViewType + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + + /// + /// Gets or sets the createdAt timestamp for the response. + /// + [XmlAttribute("createdAt")] + public string? CreatedAt { get; set; } + + /// + /// Gets or sets the updatedAt timestamp for the response. + /// + [XmlAttribute("updatedAt")] + public string? UpdatedAt { get; set; } + + /// + /// Gets or sets the lastAccessedAt timestamp for the response. + /// + [XmlAttribute("lastAccessedAt")] + public string? LastAccessedAt { get; set; } + + /// + /// Gets or sets the shared flag for the response. + /// + [XmlAttribute("shared")] + public bool Shared { get; set; } + + /// + /// Gets or sets the view for the response. + /// + [XmlElement("view")] + public ViewType? View { get; set; } + + Guid? ICustomViewType.ViewId => View?.Id; + + string? ICustomViewType.ViewName => View?.Name; + + /// + /// Gets or sets the workbook for the response. + /// + [XmlElement("workbook")] + public WorkbookType? Workbook { get; set; } + + IRestIdentifiable? IWithWorkbookReferenceType.Workbook => Workbook; + + /// + /// Gets or sets the owner for the response. + /// + [XmlElement("owner")] + public OwnerType? Owner { get; set; } + + IRestIdentifiable? IWithOwnerType.Owner => Owner; + + /// + /// Class representing a REST API view on the response. + /// + public class ViewType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + + /// + /// Class representing a REST API workbook on the response. + /// + public class WorkbookType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + + /// + /// Class representing a REST API owner on the response. + /// + public class OwnerType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + + /// + /// Gets or sets the name for the response. + /// + [XmlAttribute("name")] + public string? Name { get; set; } + } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateProjectResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateProjectResponse.cs index a76aa91..4b34728 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateProjectResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/UpdateProjectResponse.cs @@ -88,14 +88,14 @@ public class ProjectType : IRestIdentifiable, IProjectType public OwnerType? Owner { get; set; } /// - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; #region - Object Specific Types - /// /// Class representing a REST API user response. /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// /// Gets or sets the ID for the response. diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/UsersWithCustomViewAsDefaultViewResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/UsersWithCustomViewAsDefaultViewResponse.cs new file mode 100644 index 0000000..030bf63 --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/UsersWithCustomViewAsDefaultViewResponse.cs @@ -0,0 +1,48 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Xml.Serialization; + +namespace Tableau.Migration.Api.Rest.Models.Responses +{ + /// + /// Class representing a list of users with a custom view as the default view response. + /// + [XmlType(XmlTypeName)] + public class UsersWithCustomViewAsDefaultViewResponse : PagedTableauServerResponse + { + /// + /// Gets or sets the users for the response. + /// + [XmlArray("users")] + [XmlArrayItem("user")] + public override UserType[] Items { get; set; } = Array.Empty(); + + /// + /// Class representing a REST API user response. + /// + public class UserType : IRestIdentifiable + { + /// + /// Gets or sets the ID for the response. + /// + [XmlAttribute("id")] + public Guid Id { get; set; } + } + } +} diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs index 2589b41..b92537b 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbookResponse.cs @@ -144,7 +144,7 @@ internal WorkbookType(IWorkbookDetailsType response) public OwnerType? Owner { get; set; } /// - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// /// Gets or sets the tags for the response. @@ -239,7 +239,7 @@ public class LocationType : ILocationType /// /// Class representing a REST API user response. /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// [XmlAttribute("id")] @@ -252,10 +252,10 @@ public OwnerType() { } /// - /// Constructor to build from + /// Constructor to build from /// /// The owner to copy from. - public OwnerType(IOwnerType owner) + public OwnerType(IRestIdentifiable owner) { Id = owner.Id; } diff --git a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs index 3c1b5b9..3bd0f22 100644 --- a/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs +++ b/src/Tableau.Migration/Api/Rest/Models/Responses/WorkbooksResponse.cs @@ -108,7 +108,7 @@ public class WorkbookType : IWorkbookType public OwnerType? Owner { get; set; } /// - IOwnerType? IWithOwnerType.Owner => Owner; + IRestIdentifiable? IWithOwnerType.Owner => Owner; /// /// Gets or sets the tags for the response. @@ -193,7 +193,7 @@ public LocationType(ILocationType response) /// /// Class representing a REST API user response. /// - public class OwnerType : IOwnerType + public class OwnerType : IRestIdentifiable { /// [XmlAttribute("id")] @@ -206,10 +206,10 @@ public OwnerType() { } /// - /// Constructor to build from + /// Constructor to build from /// /// The owner to copy from. - public OwnerType(IOwnerType owner) + public OwnerType(IRestIdentifiable owner) { Id = owner.Id; } diff --git a/src/Tableau.Migration/Api/Rest/Models/Types/CustomViewFileTypes.cs b/src/Tableau.Migration/Api/Rest/Models/Types/CustomViewFileTypes.cs new file mode 100644 index 0000000..71d6c0b --- /dev/null +++ b/src/Tableau.Migration/Api/Rest/Models/Types/CustomViewFileTypes.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Api.Rest.Models.Types +{ + /// + /// Class containing custom view file type constants. + /// + public class CustomViewFileTypes : StringEnum + { + /// + /// Gets the name of the Tde custom view file type. + /// + public const string Json = "json"; + } +} + diff --git a/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs b/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs index 7614808..3a0381a 100644 --- a/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs +++ b/src/Tableau.Migration/Api/Rest/RestUrlPrefixes.cs @@ -36,6 +36,7 @@ internal static class RestUrlPrefixes [typeof(IViewsApiClient)] = Views, [typeof(IWorkbooksApiClient)] = Workbooks, [typeof(ITasksApiClient)] = Tasks, + [typeof(ICustomViewsApiClient)] = CustomViews } .ToImmutableDictionary(InheritedTypeComparer.Instance); @@ -52,6 +53,7 @@ internal static class RestUrlPrefixes public const string Workbooks = "workbooks"; public const string Schedules = "schedules"; public const string Tasks = "tasks"; + public const string CustomViews = "customviews"; public static string GetUrlPrefix() where TApiClient : IContentApiClient diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Api/CustomViewsRestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/Api/CustomViewsRestApiSimulator.cs new file mode 100644 index 0000000..680e708 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Api/CustomViewsRestApiSimulator.cs @@ -0,0 +1,106 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Linq; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Simulation.Rest.Net; +using Tableau.Migration.Api.Simulation.Rest.Net.Requests; +using Tableau.Migration.Api.Simulation.Rest.Net.Responses; +using Tableau.Migration.Net.Simulation; +using static Tableau.Migration.Api.Simulation.Rest.Net.Requests.RestUrlPatterns; + +namespace Tableau.Migration.Api.Simulation.Rest.Api +{ + /// + /// Object that defines simulation of Tableau REST API custom view methods. + /// + public sealed class CustomViewsRestApiSimulator + { + /// + /// Gets the simulated custom view query API method. + /// + public MethodSimulator QueryCustomViews { get; } + + /// + /// Gets the simulated custom view download API method. + /// + public MethodSimulator DownloadCustomView { get; } + + /// + /// Gets the simulated commit custom view upload API method. + /// + public MethodSimulator CommitCustomViewUpload { get; } + + + /// + /// Gets the simulated get custom view default users API method. + /// + public MethodSimulator GetCustomViewDefaultUsers { get; } + + + /// + /// Sets the simulated set custom view default users API method. + /// + public MethodSimulator SetCustomViewDefaultUsers { get; } + + /// + /// Creates a new object. + /// + /// A response simulator to setup with REST API methods. + public CustomViewsRestApiSimulator(TableauApiResponseSimulator simulator) + { + QueryCustomViews = simulator.SetupRestPagedList( + SiteUrl(RestUrlPrefixes.CustomViews), + (data, request) => + { + return data.CustomViews.Select(x => new CustomViewsResponse.CustomViewResponseType(x)).ToList(); + }); + + DownloadCustomView = simulator.SetupRestDownloadById( + SiteEntityUrl( + postSitePreEntitySuffix: RestUrlPrefixes.CustomViews, + postEntitySuffix: "content", + useExperimental: true), + (data) => data.CustomViewFiles, 4); + + CommitCustomViewUpload = simulator.SetupRestPost( + SiteUrl(RestUrlPrefixes.CustomViews, useExperimental: true), + new RestCommitCustomViewUploadResponseBuilder(simulator.Data, simulator.Serializer)); + + GetCustomViewDefaultUsers = simulator.SetupRestPagedList( + SiteEntityUrl(RestUrlPrefixes.CustomViews, "/default/users"), + (data, request) => + { + var customViewId = request.GetIdAfterSegment(RestUrlPrefixes.CustomViews); + + if (data.CustomViewDefaultUsers == null || customViewId == null) + return []; + + data.CustomViewDefaultUsers.TryGetValue(customViewId.Value, out List? defaultUsers); + + return defaultUsers == null ? [] : defaultUsers; + }); + + + SetCustomViewDefaultUsers = simulator.SetupRestPost( + SiteEntityUrl(RestUrlPrefixes.CustomViews, "/default/users"), + new RestCustomViewDefaultUsersAddResponseBuilder(simulator.Data, simulator.Serializer)); + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestApiRequestMatcher.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestApiRequestMatcher.cs index a74411c..eabec3f 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestApiRequestMatcher.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestApiRequestMatcher.cs @@ -68,7 +68,9 @@ public virtual bool Matches(HttpRequestMessage request) } var restApiVersionGroup = restApiPathMatch.Groups[RestUrlPatterns.VersionGroupName]; - if (restApiVersionGroup is null || !decimal.TryParse(restApiVersionGroup.Value, out var restApiVersion)) + if (restApiVersionGroup is null + || (!decimal.TryParse(restApiVersionGroup.Value, out var restApiVersion) + && restApiVersionGroup.Value != ApiClient.EXPERIMENTAL_API_VERSION)) { return false; } diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs index 7b5a243..caefacf 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Requests/RestUrlPatterns.cs @@ -42,23 +42,44 @@ internal static class RestUrlPatterns public static readonly string EntityId = IdPattern(EntityIdGroupName); - public static Regex RestApiUrl(string suffix) => new($"""^/api/(?<{VersionGroupName}>\d+.\d+)/{suffix.TrimPaths()}/?$""", RegexOptions.IgnoreCase | RegexOptions.Compiled); + public static Regex RestApiUrl(string suffix, bool useExperimental = false) + { + string pattern; + + if (useExperimental) + { + pattern = $"""^/api/(?<{VersionGroupName}>{ApiClient.EXPERIMENTAL_API_VERSION})/{suffix.TrimPaths()}/?$"""; + } + else + { + pattern = $"""^/api/(?<{VersionGroupName}>\d+.\d+)/{suffix.TrimPaths()}/?$"""; + + } + + return new(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled); + } - public static Regex SiteUrl(string postSiteSuffix) => RestApiUrl($"""/sites/{SiteId}/{postSiteSuffix.TrimPaths()}"""); + public static Regex SiteUrl(string postSiteSuffix, bool useExperimental = false) + => RestApiUrl($"""/sites/{SiteId}/{postSiteSuffix.TrimPaths()}""", useExperimental); public static Regex EntityUrl(string preEntitySuffix) => RestApiUrl($"""{preEntitySuffix.TrimPaths()}/{EntityId}"""); - public static Regex SiteEntityUrl(string postSitePreEntitySuffix, string? postEntitySuffix = null) + public static Regex SiteEntityUrl( + string postSitePreEntitySuffix, + string? postEntitySuffix = null, + bool useExperimental = false) { var trimmedSuffix = postEntitySuffix?.TrimPaths(); trimmedSuffix = string.IsNullOrEmpty(trimmedSuffix) ? string.Empty : $"/{trimmedSuffix}"; - return SiteUrl($"""{postSitePreEntitySuffix.TrimPaths()}/{EntityId}{trimmedSuffix}"""); + return SiteUrl($"""{postSitePreEntitySuffix.TrimPaths()}/{EntityId}{trimmedSuffix}""", useExperimental); } - public static Regex SiteEntityTagsUrl(string postSitePreEntitySuffix, string? postTagsSuffix = null) => SiteEntityUrl(postSitePreEntitySuffix, $"tags/{postTagsSuffix}".TrimEnd('/')); + public static Regex SiteEntityTagsUrl(string postSitePreEntitySuffix, string? postTagsSuffix = null) + => SiteEntityUrl(postSitePreEntitySuffix, $"tags/{postTagsSuffix}".TrimEnd('/')); - public static Regex SiteEntityTagUrl(string postSitePreEntitySuffix) => SiteEntityTagsUrl(postSitePreEntitySuffix, new Regex(NamePattern, RegexOptions.IgnoreCase).ToString()); + public static Regex SiteEntityTagUrl(string postSitePreEntitySuffix) + => SiteEntityTagsUrl(postSitePreEntitySuffix, new Regex(NamePattern, RegexOptions.IgnoreCase).ToString()); public static IEnumerable<(string Key, Regex ValuePattern)> SiteCommitFileUploadQueryString(string typeParam) { diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitCustomViewUploadResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitCustomViewUploadResponseBuilder.cs new file mode 100644 index 0000000..6cd0bd0 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCommitCustomViewUploadResponseBuilder.cs @@ -0,0 +1,119 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Linq; +using System.Text; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Content; +using Tableau.Migration.Net; + +namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses +{ + internal class RestCommitCustomViewUploadResponseBuilder + : RestCommitUploadResponseBuilder + { + public RestCommitCustomViewUploadResponseBuilder( + TableauData data, + IHttpContentSerializer serializer) + : base( + data, + serializer, + data => data.CustomViews, + data => data.CustomViewFiles, + (data, cv, file) => data.AddCustomView(cv, file)) + { } + + protected override CustomViewResponse.CustomViewType? GetExistingContentItem( + CommitCustomViewPublishRequest commitRequest) + { + var commitCustomView = Guard.AgainstNull(commitRequest.CustomView, () => commitRequest.CustomView); + + var targetCustomView = Data.CustomViews + .SingleOrDefault(wb => + string.Equals(wb.Name, commitCustomView.Name, CustomView.NameComparison)); + + return targetCustomView; + } + + protected override CustomViewResponse.CustomViewType BuildContent( + CommitCustomViewPublishRequest commitRequest, + ref byte[] commitFileData, + CustomViewResponse.CustomViewType? existingContent, + UsersResponse.UserType currentUser, + bool overwrite) + { + var commitCustomView = Guard.AgainstNull(commitRequest.CustomView, () => commitRequest.CustomView); + + SimulatedCustomViewData? simulatedFileData; + try + { + simulatedFileData = Encoding.Default.GetString(commitFileData).FromJson(); + if (simulatedFileData is null) + { + throw new BuildResponseException(System.Net.HttpStatusCode.BadRequest, 8, "Unable to parse file", ""); + } + } + catch (Exception) + { + throw new BuildResponseException(System.Net.HttpStatusCode.BadRequest, 8, "Unable to parse file", ""); + } + + var targetCustomView = existingContent ?? new CustomViewResponse.CustomViewType + { + Id = Guid.NewGuid(), + Name = commitCustomView.Name, + CreatedAt = DateTime.UtcNow.ToString(), + UpdatedAt = DateTime.UtcNow.ToString(), + LastAccessedAt = DateTime.UtcNow.ToString(), + Shared = true, + Owner = new CustomViewResponse.CustomViewType.OwnerType + { + Id = Guid.NewGuid() + }, + Workbook = new CustomViewResponse.CustomViewType.WorkbookType + { + Id = Guid.NewGuid() + }, + View = new CustomViewResponse.CustomViewType.ViewType(Guid.NewGuid(), $"ViewName{Guid.NewGuid()}") + }; + + targetCustomView.Name = commitCustomView.Name; + targetCustomView.UpdatedAt = DateTime.UtcNow.ToString(); + targetCustomView.Shared = commitCustomView.Shared; + targetCustomView.Workbook = new CustomViewResponse.CustomViewType.WorkbookType + { + Id = commitCustomView.Workbook == null ? Guid.NewGuid() : commitCustomView.Workbook.Id + }; + + targetCustomView.Owner = new() + { + Id = currentUser.Id + }; + + targetCustomView.View = new CustomViewResponse.CustomViewType.ViewType(Guid.NewGuid(), $"ViewName{Guid.NewGuid()}"); + + commitFileData = Encoding.Default.GetBytes(simulatedFileData.ToJson()); + + return targetCustomView; + } + + protected override CustomViewResponse.CustomViewType BuildResponse(CustomViewResponse.CustomViewType customView) + => customView; + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCustomViewDefaultUsersAddResponseBuilder.cs b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCustomViewDefaultUsersAddResponseBuilder.cs new file mode 100644 index 0000000..b3d9ba6 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/Rest/Net/Responses/RestCustomViewDefaultUsersAddResponseBuilder.cs @@ -0,0 +1,98 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Api.Rest; +using Tableau.Migration.Api.Rest.Models.Requests; +using Tableau.Migration.Api.Rest.Models.Responses; +using Tableau.Migration.Api.Simulation.Rest.Net.Requests; +using Tableau.Migration.Net; + + +namespace Tableau.Migration.Api.Simulation.Rest.Net.Responses +{ + internal class RestCustomViewDefaultUsersAddResponseBuilder : RestApiResponseBuilderBase + { + public RestCustomViewDefaultUsersAddResponseBuilder(TableauData data, IHttpContentSerializer serializer) + : base(data, serializer, requiresAuthentication: true) + { } + + protected override ValueTask<(CustomViewAsUsersDefaultViewResponse Response, HttpStatusCode ResponseCode)> BuildResponseAsync( + HttpRequestMessage request, + CancellationToken cancel) + { + if (request?.Content is null) + return BuildEmptyErrorResponseAsync(HttpStatusCode.BadRequest, 0, "Request or content cannot be null.", ""); + + var customViewId = request.GetIdAfterSegment(RestUrlPrefixes.CustomViews); + var usersToAdd = request.GetTableauServerRequest()?.Users; + + if (usersToAdd is null || customViewId is null) + { + return BuildEmptyErrorResponseAsync( + HttpStatusCode.BadRequest, + 0, + $"Request must be of the type {nameof(SetCustomViewDefaultUsersRequest.UserType)} and not null. CustomView ID should not be null", + ""); + } + + var customViewUsers = new List(); + foreach (var user in usersToAdd) + { + customViewUsers.Add(new() + { + Id = user.Id + }); + } + + if (Data.CustomViewDefaultUsers.TryGetValue( + customViewId.Value, + out List? value)) + { + value.AddRange(customViewUsers); + } + else + { + Data.CustomViewDefaultUsers.TryAdd(customViewId.Value, customViewUsers); + } + + + var resultUsers = new List(); + + foreach (var user in customViewUsers) + { + resultUsers.Add(new() + { + Success = true, + User = new() + { + Id = user.Id + } + }); + } + return ValueTask.FromResult((new CustomViewAsUsersDefaultViewResponse + { + Items = [.. resultUsers] + }, + HttpStatusCode.Created)); + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs b/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs index c5cfb74..ff236e5 100644 --- a/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs +++ b/src/Tableau.Migration/Api/Simulation/Rest/RestApiSimulator.cs @@ -51,12 +51,12 @@ public sealed class RestApiSimulator /// Gets the simulated job API methods. /// public JobsRestApiSimulator Jobs { get; } - + /// /// Gets the simulated schedule API methods. /// public SchedulesRestApiSimulator Schedules { get; } - + /// /// Gets the simulated task API methods. /// @@ -87,6 +87,11 @@ public sealed class RestApiSimulator /// public ViewsRestApiSimulator Views { get; } + /// + /// Gets the simulated custom view API methods. + /// + public CustomViewsRestApiSimulator CustomViews { get; } + /// /// Gets the simulated workbook API methods. /// @@ -130,7 +135,7 @@ private static ServerSessionResponse.SessionType BuildCurrentSession(TableauData }; var adminLevel = SiteRoleMapping.GetAdministratorLevel(user.SiteRole); - if(!AdministratorLevels.IsAMatch(adminLevel, AdministratorLevels.None)) + if (!AdministratorLevels.IsAMatch(adminLevel, AdministratorLevels.None)) { response.Site.ExtractEncryptionMode = site.ExtractEncryptionMode; } @@ -156,6 +161,7 @@ public RestApiSimulator(TableauApiResponseSimulator simulator) Workbooks = new(simulator); Files = new(simulator); Views = new(simulator); + CustomViews = new(simulator); QueryServerInfo = simulator.SetupRestGet(RestApiUrl("serverinfo"), d => d.ServerInfo, requiresAuthentication: false); GetCurrentServerSession = simulator.SetupRestGet(RestApiUrl("sessions/current"), BuildCurrentSession); diff --git a/src/Tableau.Migration/Api/Simulation/SimulatedCustomViewData.cs b/src/Tableau.Migration/Api/Simulation/SimulatedCustomViewData.cs new file mode 100644 index 0000000..be04b78 --- /dev/null +++ b/src/Tableau.Migration/Api/Simulation/SimulatedCustomViewData.cs @@ -0,0 +1,71 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Tableau.Migration.Api.Simulation +{ + /// + /// Object that holds simulated custom view file data + /// + public class SimulatedCustomViewData + { + /// + /// Simulated custom views data + /// + public List CustomViews { get; set; } = new List(); + + /// + /// Simulated custom view type data + /// + public class SimulatedCustomViewType + { + /// + /// Constructor for the simulated custom view. + /// + /// + /// + /// + public SimulatedCustomViewType(bool isSourceView, string viewName, string tcv) + { + IsSourceView = isSourceView; + ViewName = viewName; + Tcv = tcv; + } + + /// + /// Flag that indicates if is the source view + /// for the Custom View encoded in . + /// + [JsonPropertyName("isSourceView")] + public bool IsSourceView { get; set; } + + /// + /// Name of the view which this custom view is based on. + /// + [JsonPropertyName("viewName")] + public string ViewName { get; set; } + + /// + /// The encoded Custom View Definition + /// + [JsonPropertyName("tcv")] + public string Tcv { get; set; } + } + } +} diff --git a/src/Tableau.Migration/Api/Simulation/TableauData.cs b/src/Tableau.Migration/Api/Simulation/TableauData.cs index b365f92..ee0c239 100644 --- a/src/Tableau.Migration/Api/Simulation/TableauData.cs +++ b/src/Tableau.Migration/Api/Simulation/TableauData.cs @@ -95,17 +95,17 @@ public sealed class TableauData /// Gets or sets the jobs. /// public ConcurrentSet Jobs { get; set; } = new(); - + /// /// Gets or sets the schedules. /// public ConcurrentSet Schedules { get; set; } = new(); - + /// /// Gets or sets the schedules extract refresh tasks. /// public ConcurrentSet ScheduleExtractRefreshTasks { get; set; } = new(); - + /// /// Gets or sets the Tableau Server extract refresh tasks. /// @@ -171,6 +171,23 @@ public sealed class TableauData /// public ConcurrentDictionary> Files { get; set; } = new(); + /// + /// Gets or sets the custom views. + /// + public ConcurrentSet CustomViews { get; set; } = new(); + + + /// + /// Gets or sets the custom view fileData contents, by ID. + /// + public ConcurrentDictionary CustomViewFiles { get; set; } = new(); + + + + /// + /// Gets or sets the custom view default users contents, by ID. + /// + public ConcurrentDictionary> CustomViewDefaultUsers { get; set; } = new(); #region - Relationships - @@ -509,6 +526,18 @@ internal void AddView( Views.Add(view); } + /// + /// Adds a custom view to simulated dataset. + /// + /// The metadata + /// A byte array representing the custom view. If null, empty array is used + internal void AddCustomView( + CustomViewResponse.CustomViewType customView, + byte[]? fileData) + { + CustomViews.Add(customView); + CustomViewFiles[customView.Id] = fileData ?? []; + } internal void AddDefaultProjectPermissions(Guid projectId, string contentTypeUrlSegment, PermissionsType permissions) { DefaultProjectPermissions.AddOrUpdate( diff --git a/src/Tableau.Migration/Api/SitesApiClient.cs b/src/Tableau.Migration/Api/SitesApiClient.cs index b5e0b27..3204ab1 100644 --- a/src/Tableau.Migration/Api/SitesApiClient.cs +++ b/src/Tableau.Migration/Api/SitesApiClient.cs @@ -26,7 +26,6 @@ using Tableau.Migration.Api.Rest.Models.Responses; using Tableau.Migration.Api.Tags; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Content.Search; @@ -62,6 +61,7 @@ public SitesApiClient( IViewsApiClient viewsApiClient, IFlowsApiClient flowsApiClient, ITasksApiClient tasksApiClient, + ICustomViewsApiClient customViewsApiClient, ISharedResourcesLocalizer sharedResourcesLocalizer) : base(restRequestBuilderFactory, finderFactory, loggerFactory, sharedResourcesLocalizer) { @@ -77,7 +77,8 @@ public SitesApiClient( Workbooks = workbooksApiClient; Views = viewsApiClient; Flows = flowsApiClient; - + CustomViews = customViewsApiClient; + _tasksApiClient = tasksApiClient; } @@ -92,7 +93,8 @@ public SitesApiClient( { typeof(IFlow), client => client.Flows }, { typeof(IServerSchedule), client => client.Schedules }, { typeof(IServerExtractRefreshTask), client => client.ServerTasks }, - { typeof(ICloudExtractRefreshTask), client => client.CloudTasks } + { typeof(ICloudExtractRefreshTask), client => client.CloudTasks }, + { typeof(ICustomView), client => client.CustomViews }, } .ToImmutableDictionary(InheritedTypeComparer.Instance); @@ -110,7 +112,7 @@ public SitesApiClient( /// public IJobsApiClient Jobs { get; } - + /// public ISchedulesApiClient Schedules { get; } @@ -133,11 +135,14 @@ public SitesApiClient( public IFlowsApiClient Flows { get; } /// - public IServerTasksApiClient ServerTasks + public ICustomViewsApiClient CustomViews { get; } + + /// + public IServerTasksApiClient ServerTasks => ReturnForInstanceType(TableauInstanceType.Server, _sessionProvider.InstanceType, _tasksApiClient); /// - public ICloudTasksApiClient CloudTasks + public ICloudTasksApiClient CloudTasks => ReturnForInstanceType(TableauInstanceType.Cloud, _sessionProvider.InstanceType, _tasksApiClient); /// diff --git a/src/Tableau.Migration/Api/WorkbooksApiClient.cs b/src/Tableau.Migration/Api/WorkbooksApiClient.cs index df7999f..ad1b223 100644 --- a/src/Tableau.Migration/Api/WorkbooksApiClient.cs +++ b/src/Tableau.Migration/Api/WorkbooksApiClient.cs @@ -116,7 +116,17 @@ public async Task> GetAllWorkbooksAsync( var owner = await FindOwnerAsync(item, false, c).ConfigureAwait(false); if (project is null || owner is null) - continue; //Warnings will be logged by prior method calls. + { + Logger.LogWarning( + SharedResourcesLocalizer[SharedResourceKeys.WorkbookSkippedMissingReferenceWarning], + item.Id, + item.Name, + item.Project!.Id, + project is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found], + item.Owner!.Id, + owner is null ? SharedResourcesLocalizer[SharedResourceKeys.NotFound] : SharedResourcesLocalizer[SharedResourceKeys.Found]); + continue; + } results.Add(new Workbook(item, project, owner)); } diff --git a/src/Tableau.Migration/Config/ConfigReader.cs b/src/Tableau.Migration/Config/ConfigReader.cs index 1d99a19..9e75602 100644 --- a/src/Tableau.Migration/Config/ConfigReader.cs +++ b/src/Tableau.Migration/Config/ConfigReader.cs @@ -36,8 +36,6 @@ public class ConfigReader : IConfigReader public ConfigReader(IOptionsMonitor optionsMonitor) { _optionsMonitor = optionsMonitor; - ValidateOptions(Get()); - _optionsMonitor.OnChange(ValidateOptions); } /// @@ -71,21 +69,7 @@ public ContentTypesOptions Get() }; } - throw new NotSupportedException( - $"Content type specific options are not supported for {typeof(TContent)} since it is not supported for migration."); - } - - internal void ValidateOptions(MigrationSdkOptions? options) - { - options ??= Get(); - - foreach (var byContentTypeName in options.ContentTypes.GroupBy(v => v.Type)) - { - if (byContentTypeName.First().IsContentTypeValid() && byContentTypeName.Count() > 1) - { - throw new InvalidOperationException($"Duplicate content type names found in {(nameof(MigrationSdkOptions.ContentTypes))} section of the configuration."); - } - } + throw new NotSupportedException($"Content type specific options are not supported for {typeof(TContent)} since it is not supported for migration."); } } } diff --git a/src/Tableau.Migration/Config/NetworkOptions.cs b/src/Tableau.Migration/Config/NetworkOptions.cs index e67d125..b9dd16d 100644 --- a/src/Tableau.Migration/Config/NetworkOptions.cs +++ b/src/Tableau.Migration/Config/NetworkOptions.cs @@ -51,6 +51,11 @@ public static class Defaults /// The default Network Exceptions Logging Flag - Disabled as Default. /// public const bool LOG_EXCEPTIONS_ENABLED = false; + + /// + /// The default omitted user agent comment. + /// + public const string? USER_AGENT_COMMENT = null; } /// @@ -103,6 +108,16 @@ public bool ExceptionsLoggingEnabled } private bool? _exceptionsLoggingEnabled; + /// + /// Gets or sets the comment to include in the HTTP user agent header, or null to omit the user agent comment. + /// + public string? UserAgentComment + { + get => _userAgentComment ?? Defaults.USER_AGENT_COMMENT; + set => _userAgentComment = value; + } + private string? _userAgentComment; + /// /// Resilience options related to Tableau connections. /// This configuration adds a transient-fault-handling layer for all communication to Tableau. diff --git a/src/Tableau.Migration/Config/UniqueContentTypesValidator.cs b/src/Tableau.Migration/Config/UniqueContentTypesValidator.cs new file mode 100644 index 0000000..5868d1d --- /dev/null +++ b/src/Tableau.Migration/Config/UniqueContentTypesValidator.cs @@ -0,0 +1,51 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Immutable; +using System.Linq; +using Microsoft.Extensions.Options; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Config +{ + internal sealed class UniqueContentTypesValidator : IValidateOptions + { + private readonly ISharedResourcesLocalizer _localizer; + + public UniqueContentTypesValidator(ISharedResourcesLocalizer localizer) + { + _localizer = localizer; + } + + public ValidateOptionsResult Validate(string? name, MigrationSdkOptions options) + { + var duplicateContentErrors = options.ContentTypes + .GroupBy(v => v.Type, StringComparer.OrdinalIgnoreCase) + .Where(g => g.First().IsContentTypeValid() && g.Count() > 1) + .Select(g => string.Format(_localizer[SharedResourceKeys.DuplicateContentTypeConfigurationMessage], g.Key)) + .ToImmutableArray(); + + if (duplicateContentErrors.Any()) + { + return ValidateOptionsResult.Fail(duplicateContentErrors); + } + + return ValidateOptionsResult.Success; + } + } +} diff --git a/src/Tableau.Migration/Constants.cs b/src/Tableau.Migration/Constants.cs index 78eb2bb..8e83427 100644 --- a/src/Tableau.Migration/Constants.cs +++ b/src/Tableau.Migration/Constants.cs @@ -96,14 +96,24 @@ public static class Constants #region - Internal Constants - /// - /// The default prefix for the user agent string + /// The comment for the python user agent. /// - internal const string USER_AGENT_PREFIX = "TableauMigrationSDK"; + internal const string PYTHON_USER_AGENT_COMMENT = "Python"; + + /// + /// The comment for the python user agent. + /// + internal const string PYTHON_ENVIRONMENT_VARIABLE_PREFIX = "MigrationSDK__"; /// - /// The default suffix for the python user agent string + /// The comment for the python user agent. /// - internal const string USER_AGENT_PYTHON_SUFFIX = "-Python"; + internal const string PYTHON_USER_AGENT_COMMENT_CONFIG_KEY = PYTHON_ENVIRONMENT_VARIABLE_PREFIX + "Network__UserAgentComment"; + + /// + /// The default prefix for the user agent string. + /// + internal const string USER_AGENT_PREFIX = "TableauMigrationSDK"; #endregion } diff --git a/src/Tableau.Migration/Content/CustomView.cs b/src/Tableau.Migration/Content/CustomView.cs new file mode 100644 index 0000000..503acdf --- /dev/null +++ b/src/Tableau.Migration/Content/CustomView.cs @@ -0,0 +1,110 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Tableau.Migration.Api.Rest.Models; + +namespace Tableau.Migration.Content +{ + internal class CustomView : ContentBase, ICustomView + { + /// + public string CreatedAt { get; } + + /// + public string UpdatedAt { get; } + + /// + public string? LastAccessedAt { get; } + + /// + public bool Shared { get; set; } + + /// + public Guid BaseViewId { get; } + + /// + public string BaseViewName { get; } + + /// + public IContentReference Workbook { get; set; } + + /// + public IContentReference Owner { get; set; } + + internal CustomView( + Guid id, + string? name, + string? createdAt, + string? updatedAt, + string? lastAccessedAt, + bool shared, + Guid? viewId, + string? viewName, + IContentReference workbook, + IContentReference owner) + : base(new ContentReferenceStub( + Guard.AgainstDefaultValue(id, () => id), + string.Empty, + new(Guard.AgainstNullEmptyOrWhiteSpace(name, () => name)))) + { + CreatedAt = createdAt ?? string.Empty; + UpdatedAt = updatedAt ?? string.Empty; + LastAccessedAt = lastAccessedAt; + Shared = shared; + + Guard.AgainstNull(viewId, () => viewId); + BaseViewId = Guard.AgainstDefaultValue(viewId.Value, () => viewId); + + BaseViewName = Guard.AgainstNullEmptyOrWhiteSpace(viewName, () => viewName); + Workbook = workbook; + Owner = owner; + } + + public CustomView( + ICustomViewType response, + IContentReference workbook, + IContentReference owner) + : this( + response.Id, + response.Name, + response.CreatedAt, + response.UpdatedAt, + response.LastAccessedAt, + response.Shared, + response.ViewId, + response.ViewName, + workbook, + owner) + { } + + public CustomView(ICustomView customView) + : this( + customView.Id, + customView.Name, + customView.CreatedAt, + customView.UpdatedAt, + customView.LastAccessedAt, + customView.Shared, + customView.BaseViewId, + customView.BaseViewName, + customView.Workbook, + customView.Owner) + { } + + } +} diff --git a/src/Tableau.Migration/Content/Files/ContentTypeFilePathResolver.cs b/src/Tableau.Migration/Content/Files/ContentTypeFilePathResolver.cs index efa14c3..0b51332 100644 --- a/src/Tableau.Migration/Content/Files/ContentTypeFilePathResolver.cs +++ b/src/Tableau.Migration/Content/Files/ContentTypeFilePathResolver.cs @@ -42,6 +42,10 @@ public string ResolveRelativePath(TContent contentItem, string origina { return Path.Combine("workbooks", $"workbook-{wb.Id:N}{extension}"); } + else if (contentItem is ICustomView cv) + { + return Path.Combine("customviews", $"customview-{cv.Id:N}{extension}"); + } throw new ArgumentException($"Cannot generate a file store path for content type {typeof(TContent).Name}"); } diff --git a/src/Tableau.Migration/Content/ICustomView.cs b/src/Tableau.Migration/Content/ICustomView.cs new file mode 100644 index 0000000..b31412a --- /dev/null +++ b/src/Tableau.Migration/Content/ICustomView.cs @@ -0,0 +1,60 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; + +namespace Tableau.Migration.Content +{ + /// + /// The interface for a custom view content item. + /// + public interface ICustomView : + IContentReference, + IWithOwner, + IWithWorkbook + { + /// + /// Gets the created timestamp. + /// + string CreatedAt { get; } + + /// + /// Gets the updated timestamp. + /// + string? UpdatedAt { get; } + + /// + /// Gets the last accessed timestamp. + /// + string? LastAccessedAt { get; } + + /// + /// Gets or sets whether the custom view is shared with all users (true) or private (false). + /// + bool Shared { get; set; } + + /// + /// Gets the ID of the view that this custom view is based on. + /// + Guid BaseViewId { get; } + + /// + /// Gets the name of the view that this custom view is based on. + /// + string BaseViewName { get; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/IPublishableCustomView.cs b/src/Tableau.Migration/Content/IPublishableCustomView.cs new file mode 100644 index 0000000..9940c37 --- /dev/null +++ b/src/Tableau.Migration/Content/IPublishableCustomView.cs @@ -0,0 +1,32 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; + +namespace Tableau.Migration.Content +{ + /// + /// Interface for the publishable version of . + /// + public interface IPublishableCustomView : ICustomView, IFileContent + { + /// + /// The list of users for whom the Custom View is the default. + /// + IList DefaultUsers { get; set; } + } +} \ No newline at end of file diff --git a/src/Tableau.Migration/Interop/PythonUserAgentSuffixProvider.cs b/src/Tableau.Migration/Content/IWithWorkbook.cs similarity index 70% rename from src/Tableau.Migration/Interop/PythonUserAgentSuffixProvider.cs rename to src/Tableau.Migration/Content/IWithWorkbook.cs index bc89092..98ae031 100644 --- a/src/Tableau.Migration/Interop/PythonUserAgentSuffixProvider.cs +++ b/src/Tableau.Migration/Content/IWithWorkbook.cs @@ -15,13 +15,16 @@ // limitations under the License. // -namespace Tableau.Migration.Interop +namespace Tableau.Migration.Content { - internal class PythonUserAgentSuffixProvider : IUserAgentSuffixProvider + /// + /// Interface to be inherited by content items with workbook. + /// + public interface IWithWorkbook : IContentReference { /// - /// Python user agent suffix + /// Gets or sets the workbook for the content item. /// - public string UserAgentSuffix { get; init; } = Constants.USER_AGENT_PYTHON_SUFFIX; + IContentReference Workbook { get; set; } } -} +} \ No newline at end of file diff --git a/src/Tableau.Migration/Content/PublishableCustomView.cs b/src/Tableau.Migration/Content/PublishableCustomView.cs new file mode 100644 index 0000000..c23c6e6 --- /dev/null +++ b/src/Tableau.Migration/Content/PublishableCustomView.cs @@ -0,0 +1,59 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Tableau.Migration.Content.Files; + +namespace Tableau.Migration.Content +{ + + internal class PublishableCustomView : CustomView, IPublishableCustomView + { + public PublishableCustomView( + ICustomView customView, + IList defaultUsers, + IContentFileHandle file) + : base(customView) + { + DefaultUsers = defaultUsers; + File = file; + } + + public IList DefaultUsers { get; set; } + public IContentFileHandle File { get; set; } + + #region - IAsyncDisposable Implementation - + + /// + /// Performs application-defined tasks associated with freeing, releasing, or resetting + /// unmanaged resources asynchronously. + /// + /// A task that represents the asynchronous dispose operation. + public async ValueTask DisposeAsync() + { + // Perform async cleanup. + await File.DisposeAsync().ConfigureAwait(false); + + // Suppress finalization. + GC.SuppressFinalize(this); + } + + #endregion + } +} diff --git a/src/Tableau.Migration/Content/Schedules/ExtractRefreshContentType.cs b/src/Tableau.Migration/Content/Schedules/ExtractRefreshContentType.cs index b642f95..370c00b 100644 --- a/src/Tableau.Migration/Content/Schedules/ExtractRefreshContentType.cs +++ b/src/Tableau.Migration/Content/Schedules/ExtractRefreshContentType.cs @@ -1,4 +1,21 @@ -namespace Tableau.Migration.Content.Schedules +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +namespace Tableau.Migration.Content.Schedules { /// /// Enum of extract refresh content types. diff --git a/src/Tableau.Migration/ContentLocation.cs b/src/Tableau.Migration/ContentLocation.cs index 374ca23..8d2e21a 100644 --- a/src/Tableau.Migration/ContentLocation.cs +++ b/src/Tableau.Migration/ContentLocation.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using Tableau.Migration.Content; namespace Tableau.Migration { @@ -46,6 +47,8 @@ public readonly record struct ContentLocation(ImmutableArray PathSegment /// public readonly bool IsEmpty { get; } = PathSegments.IsEmpty; + #region - Ctor - + /// /// Creates a new value. /// @@ -80,6 +83,10 @@ public ContentLocation(string pathSeparator, IEnumerable segments) : this(segments.ToImmutableArray(), pathSeparator) { } + #endregion + + #region - Object Overrides - + /// /// Indicates whether this value and a specified value are equal. /// @@ -101,6 +108,10 @@ public readonly override int GetHashCode() /// The string representation. public readonly override string ToString() => Path; + #endregion + + #region - Static Factory Methods - + /// /// Creates a new value with the standard user/group name separator. /// @@ -108,7 +119,7 @@ public readonly override int GetHashCode() /// The user/group name. /// The newly created . public static ContentLocation ForUsername(string domain, string username) - => new(ImmutableArray.Create(domain, username), Constants.DomainNameSeparator); + => new([domain, username], Constants.DomainNameSeparator); /// /// Creates a new value from a string. @@ -127,6 +138,57 @@ public static ContentLocation FromPath( .ToImmutableArray(), pathSeparator); + /// + /// Creates a new with the appropriate path separator for the content type. + /// + /// The content type to create the location for. + /// The location path segments. + /// + public static ContentLocation ForContentType(params string[] pathSegments) + => ForContentType(typeof(TContent), (IEnumerable)pathSegments); + + /// + /// Creates a new with the appropriate path separator for the content type. + /// + /// The content type to create the location for. + /// The location path segments. + /// + public static ContentLocation ForContentType(IEnumerable pathSegments) + => ForContentType(typeof(TContent), pathSegments); + + /// + /// Creates a new with the appropriate path separator for the content type. + /// + /// The content type to create the location for. + /// The location path segments. + /// + public static ContentLocation ForContentType(Type contentType, params string[] pathSegments) + => ForContentType(contentType, (IEnumerable)pathSegments); + + /// + /// Creates a new with the appropriate path separator for the content type. + /// + /// The content type to create the location for. + /// The location path segments. + /// + public static ContentLocation ForContentType(Type contentType, IEnumerable pathSegments) + { + string pathSeparator; + switch(contentType) + { + case Type t when t == typeof(IUser) || t == typeof(IGroup): + pathSeparator = Constants.DomainNameSeparator; + break; + default: + pathSeparator = Constants.PathSeparator; + break; + } + + return new(pathSeparator, pathSegments); + } + + #endregion + /// /// Compares the current instance with another object of the same type and returns /// an integer that indicates whether the current instance precedes, follows, or diff --git a/src/Tableau.Migration/Engine/Actions/PreflightAction.cs b/src/Tableau.Migration/Engine/Actions/PreflightAction.cs index e5880fe..af99b25 100644 --- a/src/Tableau.Migration/Engine/Actions/PreflightAction.cs +++ b/src/Tableau.Migration/Engine/Actions/PreflightAction.cs @@ -19,9 +19,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Tableau.Migration.Api.Rest.Models; using Tableau.Migration.Config; -using Tableau.Migration.Content; using Tableau.Migration.Resources; namespace Tableau.Migration.Engine.Actions @@ -43,7 +41,7 @@ public class PreflightAction : IMigrationAction /// The current migration. /// A logger. /// A localizer. - public PreflightAction(IOptions options, IMigration migration, + public PreflightAction(IOptions options, IMigration migration, ILogger logger, ISharedResourcesLocalizer localizer) { _options = options.Value; @@ -52,31 +50,9 @@ public PreflightAction(IOptions options, IMigration migration, _localizer = localizer; } - private void ValidateExtractEncryptionSetting(ISiteSettings source, ISiteSettings destination) - { - /* Any destination value is valid if source has encryption disabled. - * There won't be extract migration errors and there may be destination extracts encrypted. - */ - if (ExtractEncryptionModes.IsAMatch(source.ExtractEncryptionMode, ExtractEncryptionModes.Disabled)) - { - return; - } - - /* We don't care if the destination site is enforced/enabled as long as it supports encrypted extracts. - * There won't be extract migration errors and the destination gets to keep its preference. - */ - if (!ExtractEncryptionModes.IsAMatch(destination.ExtractEncryptionMode, ExtractEncryptionModes.Disabled)) - { - return; - } - - // Warn the user about the potential failure of encrypted extract migration. - _logger.LogWarning(_localizer[SharedResourceKeys.SiteSettingsExtractEncryptionDisabledLogMessage]); - } - private async ValueTask ManageSettingsAsync(CancellationToken cancel) { - if(!_options.ValidateSettings) + if (!_options.ValidateSettings) { _logger.LogDebug(_localizer[SharedResourceKeys.SiteSettingsSkippedDisabledLogMessage]); return Result.Succeeded(); @@ -91,7 +67,7 @@ private async ValueTask ManageSettingsAsync(CancellationToken cancel) var sourceSessionResult = sourceSessionTask.Result; var destinationSessionResult = destinationSessionTask.Result; - if(!sourceSessionResult.Success || !destinationSessionResult.Success) + if (!sourceSessionResult.Success || !destinationSessionResult.Success) { return new ResultBuilder().Add(sourceSessionResult, destinationSessionResult).Build(); } @@ -100,16 +76,12 @@ private async ValueTask ManageSettingsAsync(CancellationToken cancel) var sourceSession = sourceSessionResult.Value; var destinationSession = destinationSessionResult.Value; - if(!sourceSession.IsAdministrator || !destinationSession.IsAdministrator) + if (!sourceSession.IsAdministrator || !destinationSession.IsAdministrator) { _logger.LogDebug(_localizer[SharedResourceKeys.SiteSettingsSkippedNoAccessLogMessage]); return Result.Succeeded(); } - // Validate supported settings. - - ValidateExtractEncryptionSetting(sourceSession.Settings, destinationSession.Settings); - /* We currently don't update settings for the user because * Tableau Cloud returns an error when site administrators update site settings, * requiring server administrator access that Tableau Cloud users cannot have. diff --git a/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs index 4ae9718..846322a 100644 --- a/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/IDestinationEndpoint.cs @@ -147,5 +147,17 @@ Task> UpdateConnectionAsync( /// The cancellation token to obey. /// The result of the site update operation. Task> UpdateSiteSettingsAsync(ISiteSettingsUpdate newSiteSettings, CancellationToken cancel); + + /// + /// Update the custom view's default users. + /// + /// The ID of the custom view. + /// The list of users who have the custom view as their default. + /// The cancellation token to obey. + /// The result of the default user list update. + Task>> SetCustomViewDefaultUsersAsync( + Guid id, + IEnumerable users, + CancellationToken cancel); } } diff --git a/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs b/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs index 21ae349..0c33355 100644 --- a/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs +++ b/src/Tableau.Migration/Engine/Endpoints/TableauApiDestinationEndpoint.cs @@ -17,6 +17,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -111,5 +112,16 @@ public async Task> UpdateSiteSettingsAsync(ISiteSettingsUpdate ne { return await SiteApi.UpdateSiteAsync(newSiteSettings, cancel).ConfigureAwait(false); } + + /// + public async Task>> SetCustomViewDefaultUsersAsync( + Guid id, + IEnumerable users, + CancellationToken cancel) + { + return await SiteApi.CustomViews + .SetCustomViewDefaultUsersAsync(id, users, cancel) + .ConfigureAwait(false); + } } } diff --git a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBuilder.cs b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBuilder.cs index fa35cb0..2932c06 100644 --- a/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBuilder.cs +++ b/src/Tableau.Migration/Engine/Hooks/Filters/ContentFilterBuilder.cs @@ -28,22 +28,6 @@ namespace Tableau.Migration.Engine.Hooks.Filters /// public class ContentFilterBuilder : ContentTypeHookBuilderBase, IContentFilterBuilder { - /// - /// Creates a new empty object. - /// - public ContentFilterBuilder() - { } - - #region - Private Helper Methods - - - private IContentFilterBuilder Add(Type filterType, Func initializer) - { - AddFactoriesByType(filterType, initializer); - return this; - } - - #endregion - #region - IContentFilterBuilder Implementation - /// @@ -54,30 +38,13 @@ public virtual IContentFilterBuilder Clear() } /// - public virtual IContentFilterBuilder Add(Type genericTransformerType, IEnumerable contentTypes) - { - if (!genericTransformerType.IsGenericTypeDefinition) - throw new ArgumentException($"Type {genericTransformerType.FullName} is not a generic type definition."); - - foreach (var contentType in contentTypes) - { - var constructedType = genericTransformerType.MakeGenericType(contentType); - - object transformerFactory(IServiceProvider services) - { - return services.GetRequiredService(constructedType); - } - - Add(constructedType, transformerFactory); - } - - return this; - } + new public virtual IContentFilterBuilder Add(Type genericFilterType, IEnumerable contentTypes) + => (IContentFilterBuilder)base.Add(genericFilterType, contentTypes); /// public virtual IContentFilterBuilder Add(IContentFilter filter) where TContent : IContentReference - => Add(typeof(IContentFilter), s => filter); + => (IContentFilterBuilder)Add(typeof(IContentFilter), s => filter); /// public virtual IContentFilterBuilder Add(Func? filterFactory = null) @@ -85,21 +52,19 @@ public virtual IContentFilterBuilder Add(Func services.GetRequiredService(); - return Add(typeof(IContentFilter), s => filterFactory(s)); + return (IContentFilterBuilder)Add(typeof(IContentFilter), s => filterFactory(s)); } /// public virtual IContentFilterBuilder Add(Func>, CancellationToken, Task>?>> callback) where TContent : IContentReference - => Add(typeof(IContentFilter), + => (IContentFilterBuilder)Add(typeof(IContentFilter), s => new CallbackHookWrapper, IEnumerable>>(callback)); /// public IContentFilterBuilder Add(Func>, IEnumerable>?> callback) where TContent : IContentReference - => Add( - (ctx, cancel) => Task.FromResult( - callback(ctx))); + => Add((ctx, cancel) => Task.FromResult(callback(ctx))); #endregion } diff --git a/src/Tableau.Migration/Engine/Hooks/Filters/IContentFilterBuilder.cs b/src/Tableau.Migration/Engine/Hooks/Filters/IContentFilterBuilder.cs index f79c82f..060b33b 100644 --- a/src/Tableau.Migration/Engine/Hooks/Filters/IContentFilterBuilder.cs +++ b/src/Tableau.Migration/Engine/Hooks/Filters/IContentFilterBuilder.cs @@ -36,10 +36,10 @@ public interface IContentFilterBuilder : IContentTypeHookBuilder /// /// Adds a factory to resolve the filter type. /// - /// The generic type definition for the filter to execute. + /// The generic type definition for the filter to execute. /// The content types used to construct the filter types. /// The same filter builder object for fluent API calls. - IContentFilterBuilder Add(Type genericTransformerType, IEnumerable contentTypes); + IContentFilterBuilder Add(Type genericFilterType, IEnumerable contentTypes); /// /// Adds an object to be resolved when you build a filter for the content type. @@ -76,8 +76,7 @@ IContentFilterBuilder Add(FuncThe content type. /// A synchronously callback to call for the filter. /// The same filter builder object for fluent API calls. - IContentFilterBuilder Add( - Func>, IEnumerable>?> callback) + IContentFilterBuilder Add(Func>, IEnumerable>?> callback) where TContent : IContentReference; /// diff --git a/src/Tableau.Migration/Engine/Hooks/IMigrationHookBuilder.cs b/src/Tableau.Migration/Engine/Hooks/IMigrationHookBuilder.cs index 317fb6f..b7567a6 100644 --- a/src/Tableau.Migration/Engine/Hooks/IMigrationHookBuilder.cs +++ b/src/Tableau.Migration/Engine/Hooks/IMigrationHookBuilder.cs @@ -76,8 +76,7 @@ IMigrationHookBuilder Add(FuncThe hook's context type. /// A synchronous callback to call for the hook. /// The same hook builder object for fluent API calls. - IMigrationHookBuilder Add( - Func callback) + IMigrationHookBuilder Add(Func callback) where THook : IMigrationHook; /// diff --git a/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBuilder.cs b/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBuilder.cs index 1494759..4d18a02 100644 --- a/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBuilder.cs +++ b/src/Tableau.Migration/Engine/Hooks/Mappings/ContentMappingBuilder.cs @@ -16,6 +16,7 @@ // using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -27,22 +28,6 @@ namespace Tableau.Migration.Engine.Hooks.Mappings /// public class ContentMappingBuilder : ContentTypeHookBuilderBase, IContentMappingBuilder { - /// - /// Creates a new empty object. - /// - public ContentMappingBuilder() - { } - - #region - Private Helper Methods - - - private IContentMappingBuilder Add(Type mappingType, Func initializer) - { - AddFactoriesByType(mappingType, initializer); - return this; - } - - #endregion - #region - IContentMappingBuilder Implementation - /// @@ -52,10 +37,14 @@ public virtual IContentMappingBuilder Clear() return this; } + /// + new public virtual IContentMappingBuilder Add(Type genericMappingType, IEnumerable contentTypes) + => (IContentMappingBuilder)base.Add(genericMappingType, contentTypes); + /// public virtual IContentMappingBuilder Add(IContentMapping mapping) where TContent : IContentReference - => Add(typeof(IContentMapping), s => mapping); + => (IContentMappingBuilder)Add(typeof(IContentMapping), s => mapping); /// public virtual IContentMappingBuilder Add(Func? mappingFactory = null) @@ -63,21 +52,19 @@ public virtual IContentMappingBuilder Add(Func services.GetRequiredService(); - return Add(typeof(IContentMapping), s => mappingFactory(s)); + return (IContentMappingBuilder)Add(typeof(IContentMapping), s => mappingFactory(s)); } /// public virtual IContentMappingBuilder Add(Func, CancellationToken, Task?>> callback) where TContent : IContentReference - => Add(typeof(IContentMapping), + => (IContentMappingBuilder)Add(typeof(IContentMapping), s => new CallbackHookWrapper, ContentMappingContext>(callback)); /// public IContentMappingBuilder Add(Func, ContentMappingContext?> callback) where TContent : IContentReference - => Add( - (ctx, cancel) => Task.FromResult( - callback(ctx))); + => Add((ctx, cancel) => Task.FromResult(callback(ctx))); #endregion } diff --git a/src/Tableau.Migration/Engine/Hooks/Mappings/IContentMappingBuilder.cs b/src/Tableau.Migration/Engine/Hooks/Mappings/IContentMappingBuilder.cs index 3ba7382..71875fb 100644 --- a/src/Tableau.Migration/Engine/Hooks/Mappings/IContentMappingBuilder.cs +++ b/src/Tableau.Migration/Engine/Hooks/Mappings/IContentMappingBuilder.cs @@ -16,6 +16,7 @@ // using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -32,14 +33,21 @@ public interface IContentMappingBuilder : IContentTypeHookBuilder /// The same mapping builder object for fluent API calls. IContentMappingBuilder Clear(); + /// + /// Adds a factory to resolve the mapping type. + /// + /// The generic type definition for the mapping to execute. + /// The content types used to construct the mapping types. + /// The same mapping builder object for fluent API calls. + IContentMappingBuilder Add(Type genericMappingType, IEnumerable contentTypes); + /// /// Adds an object to be resolved when you build a mapping for the content type. /// /// The content type. /// The mapping to execute. /// The same mapping builder object for fluent API calls. - IContentMappingBuilder Add( - IContentMapping mapping) + IContentMappingBuilder Add(IContentMapping mapping) where TContent : IContentReference; /// @@ -49,8 +57,7 @@ IContentMappingBuilder Add( /// The content type. /// An initializer function to create the object from, potentially from the migration-scoped dependency injection container. /// The same mapping builder object for fluent API calls. - IContentMappingBuilder Add( - Func? mappingFactory = null) + IContentMappingBuilder Add(Func? mappingFactory = null) where TMapping : IContentMapping where TContent : IContentReference; @@ -60,8 +67,7 @@ IContentMappingBuilder Add( /// The content type. /// A callback to call for the mapping. /// The same mapping builder object for fluent API calls. - IContentMappingBuilder Add( - Func, CancellationToken, Task?>> callback) + IContentMappingBuilder Add(Func, CancellationToken, Task?>> callback) where TContent : IContentReference; /// @@ -70,8 +76,7 @@ IContentMappingBuilder Add( /// The content type. /// A synchronously callback to call for the mapping. /// The same mapping builder object for fluent API calls. - IContentMappingBuilder Add( - Func, ContentMappingContext?> callback) + IContentMappingBuilder Add(Func, ContentMappingContext?> callback) where TContent : IContentReference; /// diff --git a/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilder.cs b/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilder.cs index 781517d..54ba6f5 100644 --- a/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilder.cs +++ b/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilder.cs @@ -28,22 +28,6 @@ namespace Tableau.Migration.Engine.Hooks /// public class MigrationHookBuilder : MigrationHookBuilderBase, IMigrationHookBuilder { - /// - /// Creates a new empty object. - /// - public MigrationHookBuilder() - { } - - #region - Private Helper Methods - - - private IMigrationHookBuilder Add(Type hookType, Func initializer) - { - AddFactoriesByType(hookType, initializer); - return this; - } - - #endregion - #region - IMigrationHookBuilder Implementation - /// @@ -54,55 +38,31 @@ public IMigrationHookBuilder Clear() } /// - public virtual IMigrationHookBuilder Add(Type genericHookType, IEnumerable contentTypes) - { - if (!genericHookType.IsGenericTypeDefinition) - throw new ArgumentException($"Type {genericHookType.FullName} is not a generic type definition."); - - foreach (var contentType in contentTypes) - { - var constructedType = genericHookType.MakeGenericType(contentType); - - object hookFactory(IServiceProvider services) - { - return services.GetRequiredService(constructedType); - } - - Add(constructedType, hookFactory); - } - - return this; - } + new public virtual IMigrationHookBuilder Add(Type genericHookType, IEnumerable contentTypes) + => (IMigrationHookBuilder)base.Add(genericHookType, contentTypes); /// public virtual IMigrationHookBuilder Add(THook hook) where THook : notnull - { - return Add(typeof(THook), s => hook); - } + => (IMigrationHookBuilder)Add(typeof(THook), s => hook); /// public virtual IMigrationHookBuilder Add(Func? hookFactory = null) where THook : notnull { - hookFactory ??= services => - { - return services.GetRequiredService(); - }; - return Add(typeof(THook), s => hookFactory(s)); + hookFactory ??= services => services.GetRequiredService(); + return (IMigrationHookBuilder)Add(typeof(THook), s => hookFactory(s)); } /// public virtual IMigrationHookBuilder Add(Func> callback) where THook : IMigrationHook - => Add(typeof(THook), s => new CallbackHookWrapper(callback)); + => (IMigrationHookBuilder)Add(typeof(THook), s => new CallbackHookWrapper(callback)); /// public IMigrationHookBuilder Add(Func callback) where THook : IMigrationHook - => Add( - (ctx, cancel) => Task.FromResult( - callback(ctx))); + => Add((ctx, cancel) => Task.FromResult(callback(ctx))); #endregion } diff --git a/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilderBase.cs b/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilderBase.cs index 6f1157a..f6bde0d 100644 --- a/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilderBase.cs +++ b/src/Tableau.Migration/Engine/Hooks/MigrationHookBuilderBase.cs @@ -19,6 +19,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using Microsoft.Extensions.DependencyInjection; namespace Tableau.Migration.Engine.Hooks { @@ -112,6 +113,45 @@ void AddForHookInterface(Type hookInterfaceType) } } + /// + /// + /// + /// + /// + /// + protected MigrationHookBuilderBase Add(Type filterType, Func initializer) + { + AddFactoriesByType(filterType, initializer); + return this; + } + + /// + /// + /// + /// + /// + /// + /// + protected MigrationHookBuilderBase Add(Type genericHookType, IEnumerable contentTypes) + { + if (!genericHookType.IsGenericTypeDefinition) + throw new ArgumentException($"Type {genericHookType.FullName} is not a generic type definition."); + + foreach (var contentType in contentTypes) + { + var constructedType = genericHookType.MakeGenericType(contentType); + + object hookFactory(IServiceProvider services) + { + return services.GetRequiredService(constructedType); + } + + Add(constructedType, hookFactory); + } + + return this; + } + #endregion /// diff --git a/src/Tableau.Migration/Engine/Hooks/PostPublish/Default/CustomViewDefaultUsersPostPublishHook.cs b/src/Tableau.Migration/Engine/Hooks/PostPublish/Default/CustomViewDefaultUsersPostPublishHook.cs new file mode 100644 index 0000000..701d783 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/PostPublish/Default/CustomViewDefaultUsersPostPublishHook.cs @@ -0,0 +1,61 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Tableau.Migration.Content; + +namespace Tableau.Migration.Engine.Hooks.PostPublish.Default +{ + /// + /// Hook that updates a custom view's default users after publish. + /// + public class CustomViewDefaultUsersPostPublishHook + : ContentItemPostPublishHookBase + { + private readonly IMigration _migration; + + /// + /// Creates a new object. + /// + /// The current migration. + public CustomViewDefaultUsersPostPublishHook(IMigration migration) + { + _migration = migration; + } + + /// + public override async Task?> ExecuteAsync( + ContentItemPostPublishContext ctx, + CancellationToken cancel) + { + var setResult = await _migration.Destination + .SetCustomViewDefaultUsersAsync( + ctx.DestinationItem.Id, + ctx.PublishedItem.DefaultUsers, + cancel) + .ConfigureAwait(false); + + if (!setResult.Success) + { + ctx.ManifestEntry.SetFailed(setResult.Errors); + } + + return ctx; + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBuilder.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBuilder.cs index dc008af..3fb1f5d 100644 --- a/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBuilder.cs +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/ContentTransformerBuilder.cs @@ -28,22 +28,6 @@ namespace Tableau.Migration.Engine.Hooks.Transformers /// public class ContentTransformerBuilder : ContentTypeHookBuilderBase, IContentTransformerBuilder { - /// - /// Creates a new empty object. - /// - public ContentTransformerBuilder() - { } - - #region - Private Helper Methods - - - private IContentTransformerBuilder Add(Type transformerType, Func initializer) - { - AddFactoriesByType(transformerType, initializer); - return this; - } - - #endregion - #region - IContentTransformerBuilder Implementation - /// @@ -54,48 +38,29 @@ public virtual IContentTransformerBuilder Clear() } /// - public virtual IContentTransformerBuilder Add(Type genericTransformerType, IEnumerable contentTypes) - { - if (!genericTransformerType.IsGenericTypeDefinition) - throw new ArgumentException($"Type {genericTransformerType.FullName} is not a generic type definition."); - - foreach (var contentType in contentTypes) - { - var constructedType = genericTransformerType.MakeGenericType(contentType); - - object transformerFactory(IServiceProvider services) - { - return services.GetRequiredService(constructedType); - } - - Add(constructedType, transformerFactory); - } - - return this; - } + new public virtual IContentTransformerBuilder Add(Type genericTransformerType, IEnumerable contentTypes) + => (IContentTransformerBuilder)base.Add(genericTransformerType, contentTypes); /// public virtual IContentTransformerBuilder Add(IContentTransformer transformer) - => Add(typeof(IContentTransformer), s => transformer); + => (IContentTransformerBuilder)Add(typeof(IContentTransformer), s => transformer); /// public virtual IContentTransformerBuilder Add(Func? contentTransformerFactory = null) where TTransformer : IContentTransformer { contentTransformerFactory ??= services => services.GetRequiredService(); - return Add(typeof(IContentTransformer), s => contentTransformerFactory(s)); + return (IContentTransformerBuilder)Add(typeof(IContentTransformer), s => contentTransformerFactory(s)); } /// public virtual IContentTransformerBuilder Add(Func> callback) - => Add(typeof(IContentTransformer), + => (IContentTransformerBuilder)Add(typeof(IContentTransformer), s => new CallbackHookWrapper, TPublish>(callback)); /// public IContentTransformerBuilder Add(Func callback) - => Add( - (ctx, cancel) => Task.FromResult( - callback(ctx))); + => Add((ctx, cancel) => Task.FromResult(callback(ctx))); #endregion } diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CustomViewDefaultUserReferencesTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CustomViewDefaultUserReferencesTransformer.cs new file mode 100644 index 0000000..9853f0a --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/CustomViewDefaultUserReferencesTransformer.cs @@ -0,0 +1,90 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Content; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.Transformers.Default +{ + /// + /// Transformer that transforms the list of users that have the custom view as default. + /// It sets the references of these users to those at the destination. + /// + public class CustomViewDefaultUserReferencesTransformer + : ContentTransformerBase + { + private readonly IMappedUserTransformer _userTransformer; + + /// + /// Creates a new object. + /// + /// The user transformer. + /// The logger used to log messages. + /// The string localizer. + public CustomViewDefaultUserReferencesTransformer( + IMappedUserTransformer userTransformer, + ILogger logger, + ISharedResourcesLocalizer localizer) + : base(localizer, logger) + { + _userTransformer = userTransformer; + } + + /// + public override async Task TransformAsync( + IPublishableCustomView sourceCustomView, + CancellationToken cancel) + { + var missingUsers = new List(); + + for (var i = 0; i < sourceCustomView.DefaultUsers.Count; i++) + { + var destinationUser = await _userTransformer.ExecuteAsync(sourceCustomView.DefaultUsers[i], cancel) + .ConfigureAwait(false); + + if (destinationUser is null) + { + missingUsers.Add(sourceCustomView.DefaultUsers[i].Name); + continue; + } + sourceCustomView.DefaultUsers[i] = destinationUser; + } + + LogMissingUsers(sourceCustomView.Name, missingUsers); + + return sourceCustomView; + } + + private void LogMissingUsers(string customViewName, List missingUsers) + { + if (!missingUsers.Any()) + { + return; + } + + Logger.LogDebug( + Localizer[SharedResourceKeys.CustomViewDefaultUsersTransformerNoUserRefsDebugMessage], + customViewName, + string.Join(',', missingUsers)); + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/EncryptExtractTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/EncryptExtractTransformer.cs new file mode 100644 index 0000000..0db37ff --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/EncryptExtractTransformer.cs @@ -0,0 +1,96 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Api.Rest.Models; +using Tableau.Migration.Content; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.Transformers.Default +{ + /// + /// Transformer that encrypts extracts based on the site's encryption mode. + /// + /// + public class EncryptExtractTransformer : ContentTransformerBase where TIExtractContent : IContentReference, IExtractContent + { + private readonly ILogger _logger; + private readonly ISharedResourcesLocalizer _localizer; + private readonly IMigration _migration; + private string? _siteExtractEncryptionMode = null; + + private readonly SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + + /// + /// Creates a new EncryptExtractTransformer + /// + /// + /// + /// + public EncryptExtractTransformer( + ISharedResourcesLocalizer localizer, + ILogger> logger, + IMigration migration) + : base(localizer, logger) + { + _migration = migration; + _logger = logger; + _localizer = localizer; + } + + /// + public override async Task TransformAsync(TIExtractContent itemToTransform, CancellationToken cancel) + { + // This transformer is registered with DI as scoped. + // This means a new transformer instance is created per batch per content type. + // To limit the number of _migration.Destination.GetSessionAsync calls, we cache the site's encryption mode + // after the first call and make it thread safe by wrapping it in a semaphore of count 1 which is async-compatible. + await _semaphore.WaitAsync(cancel).ConfigureAwait(false); + try + { + if (_siteExtractEncryptionMode is null) + { + var session = await _migration.Destination.GetSessionAsync(cancel).ConfigureAwait(false); + + if (!session.Success || session.Value?.Settings?.ExtractEncryptionMode is null) + { + throw new System.Exception("Unable to determine site data source encryption mode."); + } + + _siteExtractEncryptionMode = session.Value.Settings.ExtractEncryptionMode; + } + } + finally + { + _semaphore.Release(); + } + + if (ExtractEncryptionModes.IsAMatch(_siteExtractEncryptionMode, ExtractEncryptionModes.Enforced)) + { + itemToTransform.EncryptExtracts = true; + } + else if (ExtractEncryptionModes.IsAMatch(_siteExtractEncryptionMode, ExtractEncryptionModes.Disabled)) + { + itemToTransform.EncryptExtracts = false; + } + + return itemToTransform; + } + } +} diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/GroupUsersTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/GroupUsersTransformer.cs index 4870eea..810c34e 100644 --- a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/GroupUsersTransformer.cs +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/GroupUsersTransformer.cs @@ -15,6 +15,8 @@ // limitations under the License. // +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; @@ -30,7 +32,7 @@ namespace Tableau.Migration.Engine.Hooks.Transformers.Default public class GroupUsersTransformer : ContentTransformerBase { private readonly IDestinationContentReferenceFinder _userFinder; - + /// /// Creates a new object. /// @@ -50,25 +52,38 @@ public GroupUsersTransformer( IPublishableGroup sourceGroup, CancellationToken cancel) { + var missingUsers = new List(); + foreach (var user in sourceGroup.Users) { var destinationUser = await _userFinder .FindBySourceLocationAsync(user.User.Location, cancel) .ConfigureAwait(false); - if (destinationUser is not null) - { - user.User = destinationUser; - } - else + if (destinationUser is null) { - Logger.LogWarning( - Localizer[SharedResourceKeys.GroupUsersTransformerCannotAddUserWarning], - sourceGroup.Name, - user.User.Location); + missingUsers.Add(user.User.Name); + continue; } + + user.User = destinationUser; } + + LogMissingUsers(sourceGroup.Name, missingUsers); return sourceGroup; } + + private void LogMissingUsers(string groupName, List missingUsers) + { + if (!missingUsers.Any()) + { + return; + } + + Logger.LogDebug( + Localizer[SharedResourceKeys.GroupUsersTransformerCannotAddUserWarning], + groupName, + string.Join(',', missingUsers)); + } } } diff --git a/src/Tableau.Migration/Engine/Hooks/Transformers/Default/WorkbookReferenceTransformer.cs b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/WorkbookReferenceTransformer.cs new file mode 100644 index 0000000..9a54c25 --- /dev/null +++ b/src/Tableau.Migration/Engine/Hooks/Transformers/Default/WorkbookReferenceTransformer.cs @@ -0,0 +1,70 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Tableau.Migration.Content; +using Tableau.Migration.Engine.Endpoints.Search; +using Tableau.Migration.Resources; + +namespace Tableau.Migration.Engine.Hooks.Transformers.Default +{ + /// + /// Transformer that changes the workbook reference for a content item. + /// It sets the workbook reference to that of the destination. + /// + public class WorkbookReferenceTransformer + : ContentTransformerBase + where TContent : IWithWorkbook + { + private readonly IDestinationContentReferenceFinder _workbookFinder; + + /// + /// Creates a new object. + /// + /// The destination finder factory. + /// The logger used to log messages. + /// The string localizer. + public WorkbookReferenceTransformer( + IDestinationContentReferenceFinderFactory destinationFinderFactory, + ILogger> logger, + ISharedResourcesLocalizer localizer) + : base(localizer, logger) + { + _workbookFinder = destinationFinderFactory.ForDestinationContentType(); + } + + /// + public override async Task TransformAsync(TContent ctx, CancellationToken cancel) + { + var destinationWorkbook = await _workbookFinder.FindBySourceLocationAsync( + ctx.Workbook.Location, + cancel) + .ConfigureAwait(false); + + if (destinationWorkbook is not null) + { + ctx.Workbook = destinationWorkbook; + return ctx; + } + + Logger.LogDebug(Localizer[SharedResourceKeys.SourceWorkbookNotFoundLogMessage], ctx.Name, ctx.Id); + return ctx; + } + } +} diff --git a/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs index 80a0e43..661df53 100644 --- a/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Engine/IServiceCollectionExtensions.cs @@ -76,6 +76,7 @@ internal static IServiceCollection AddMigrationEngine(this IServiceCollection se services.AddScoped(); //Plan building. + services.AddSingleton(); services.AddTransient(); services.AddTransient(); services.AddTransient(); @@ -138,15 +139,19 @@ internal static IServiceCollection AddMigrationEngine(this IServiceCollection se services.AddScoped(typeof(CloudScheduleCompatibilityTransformer<>)); services.AddScoped(); services.AddScoped(); + services.AddScoped(typeof(EncryptExtractTransformer<>)); services.AddScoped(); services.AddScoped(); - + services.AddScoped(typeof(WorkbookReferenceTransformer<>)); + services.AddScoped(); + services.AddScoped(typeof(OwnerItemPostPublishHook<,>)); services.AddScoped(typeof(PermissionsItemPostPublishHook<,>)); services.AddScoped(typeof(TagItemPostPublishHook<,>)); services.AddScoped(); services.AddScoped(typeof(ChildItemsPermissionsPostPublishHook<,>)); + services.AddScoped(); //Migration engine file store. services.AddScoped(); diff --git a/src/Tableau.Migration/Engine/Migration.cs b/src/Tableau.Migration/Engine/Migration.cs index 474ade4..fc3b0f5 100644 --- a/src/Tableau.Migration/Engine/Migration.cs +++ b/src/Tableau.Migration/Engine/Migration.cs @@ -16,6 +16,7 @@ // using System; +using Microsoft.Extensions.DependencyInjection; using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Manifest; using Tableau.Migration.Engine.Pipelines; @@ -30,21 +31,21 @@ public class Migration : IMigration /// /// Creates a new object. /// - /// The migration input to initialize plan and previous manifest from. - /// An object to create pipelines with. - /// An object to create endpoints with. - /// An object to create manifests with. - public Migration(IMigrationInput input, IMigrationPipelineFactory pipelineFactory, - IMigrationEndpointFactory endpointFactory, IMigrationManifestFactory manifestFactory) + /// The service provider to use to initialize the migration. + public Migration(IServiceProvider services) { + var input = services.GetRequiredService(); Id = input.MigrationId; Plan = input.Plan; + var pipelineFactory = Plan.PipelineFactoryOverride?.Invoke(services) ?? services.GetRequiredService(); Pipeline = pipelineFactory.Create(Plan); + var endpointFactory = services.GetRequiredService(); Source = endpointFactory.CreateSource(Plan); Destination = endpointFactory.CreateDestination(Plan); + var manifestFactory = services.GetRequiredService(); Manifest = manifestFactory.Create(input, Id); } diff --git a/src/Tableau.Migration/Engine/MigrationPlan.cs b/src/Tableau.Migration/Engine/MigrationPlan.cs index a3be2cd..9bf3a20 100644 --- a/src/Tableau.Migration/Engine/MigrationPlan.cs +++ b/src/Tableau.Migration/Engine/MigrationPlan.cs @@ -20,6 +20,7 @@ using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Hooks; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Engine.Pipelines; namespace Tableau.Migration.Engine { @@ -35,6 +36,7 @@ namespace Tableau.Migration.Engine /// /// /// + /// public record MigrationPlan( Guid PlanId, [property: EnumDataType(typeof(PipelineProfile))] PipelineProfile PipelineProfile, @@ -44,7 +46,8 @@ public record MigrationPlan( IMigrationHookFactoryCollection Hooks, IMigrationHookFactoryCollection Mappings, IMigrationHookFactoryCollection Filters, - IMigrationHookFactoryCollection Transformers + IMigrationHookFactoryCollection Transformers, + Func? PipelineFactoryOverride ) : IMigrationPlan { } } diff --git a/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs b/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs index db04e96..b6dc936 100644 --- a/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs +++ b/src/Tableau.Migration/Engine/MigrationPlanBuilder.cs @@ -20,6 +20,7 @@ using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using System.Linq; +using Microsoft.Extensions.DependencyInjection; using Tableau.Migration.Api.Simulation; using Tableau.Migration.Content; using Tableau.Migration.Content.Schedules; @@ -46,6 +47,7 @@ public class MigrationPlanBuilder : IMigrationPlanBuilder private readonly ISharedResourcesLocalizer _localizer; private readonly ITableauApiSimulatorFactory _simulatorFactory; + private Func? _pipelineFactoryOverride; private ServerToCloudMigrationPlanBuilder? _serverToCloudBuilder; private PipelineProfile _pipelineProfile; @@ -98,8 +100,9 @@ public IMigrationPlanBuilder ClearExtensions() /// public IServerToCloudMigrationPlanBuilder ForServerToCloud() { - SetPipelineProfile(PipelineProfile.ServerToCloud); + SetPipelineProfile(PipelineProfile.ServerToCloud, ServerToCloudMigrationPipeline.ContentTypes); + _pipelineFactoryOverride = null; _serverToCloudBuilder ??= new(_localizer, this); ClearExtensions(); @@ -108,6 +111,44 @@ public IServerToCloudMigrationPlanBuilder ForServerToCloud() .AppendDefaultServerToCloudExtensions(); } + /// + public IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, params MigrationPipelineContentType[] supportedContentTypes) + => ForCustomPipelineFactory(pipelineFactoryOverride, (IEnumerable)supportedContentTypes); + + /// + public IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, IEnumerable supportedContentTypes) + { + SetPipelineProfile(PipelineProfile.Custom, supportedContentTypes); + + _pipelineFactoryOverride = pipelineFactoryOverride; + _serverToCloudBuilder = null; + + ClearExtensions(); + AppendDefaultExtensions(); + + return this; + } + + /// + public IMigrationPlanBuilder ForCustomPipelineFactory(params MigrationPipelineContentType[] supportedContentTypes) + where T : IMigrationPipelineFactory + => ForCustomPipelineFactory((IEnumerable)supportedContentTypes); + + /// + public IMigrationPlanBuilder ForCustomPipelineFactory(IEnumerable supportedContentTypes) + where T : IMigrationPipelineFactory + => ForCustomPipelineFactory(s => s.GetRequiredService(), supportedContentTypes); + + /// + public IMigrationPlanBuilder ForCustomPipeline(params MigrationPipelineContentType[] supportedContentTypes) + where T : IMigrationPipeline + => ForCustomPipeline((IEnumerable)supportedContentTypes); + + /// + public IMigrationPlanBuilder ForCustomPipeline(IEnumerable supportedContentTypes) + where T : IMigrationPipeline + => ForCustomPipelineFactory>(supportedContentTypes); + /// public IMigrationPlanBuilder AppendDefaultExtensions() { @@ -117,7 +158,7 @@ public IMigrationPlanBuilder AppendDefaultExtensions() Filters.Add(typeof(PreviouslyMigratedFilter<>), GetAllContentTypes()); Filters.Add(); Filters.Add(typeof(SystemOwnershipFilter<>), GetContentTypesByInterface()); - + //Standard migration transformers. Transformers.Add(); Transformers.Add(); @@ -126,6 +167,9 @@ public IMigrationPlanBuilder AppendDefaultExtensions() Transformers.Add(); Transformers.Add(); Transformers.Add(typeof(CloudScheduleCompatibilityTransformer<>), GetPublishTypesByInterface>()); + Transformers.Add(typeof(WorkbookReferenceTransformer<>), GetPublishTypesByInterface()); + Transformers.Add(); + Transformers.Add(typeof(EncryptExtractTransformer<>), GetPublishTypesByInterface()); // Post-publish hooks. Hooks.Add(typeof(OwnerItemPostPublishHook<,>), GetPostPublishTypesByInterface()); @@ -133,6 +177,7 @@ public IMigrationPlanBuilder AppendDefaultExtensions() Hooks.Add(typeof(ChildItemsPermissionsPostPublishHook<,>), GetPostPublishTypesByInterface()); Hooks.Add(typeof(TagItemPostPublishHook<,>), GetPostPublishTypesByInterface()); Hooks.Add(); + Hooks.Add(); return this; } @@ -188,10 +233,10 @@ public IMigrationPlanBuilder ToDestinationTableauCloud(Uri podUrl, string siteCo /// public IContentTransformerBuilder Transformers { get; } - private void SetPipelineProfile(PipelineProfile pipelineProfile) + private void SetPipelineProfile(PipelineProfile pipelineProfile, IEnumerable supportedContentTypes) { _pipelineProfile = pipelineProfile; - _supportedContentTypes = _pipelineProfile.GetSupportedContentTypes(); + _supportedContentTypes = supportedContentTypes.ToImmutableArray(); } private IImmutableList GetAllContentTypes() @@ -216,53 +261,93 @@ private IResult ValidateHookContentTypes() } var errors = new List(); + var listContentTypes = GetListContentTypes(); + var publishContentTypes = GetPublishContentTypes(); + + errors.AddRange(ValidateFilterContentTypes(listContentTypes)); + errors.AddRange(ValidateMappingContentTypes(listContentTypes)); + errors.AddRange(ValidateTransformerContentTypes(listContentTypes, publishContentTypes)); - var listContentTypes = _supportedContentTypes.Select(x => x.ContentType).ToImmutableHashSet(); - var publishContentTypes = _supportedContentTypes.Select(x => x.PublishType).ToImmutableHashSet(); + return Result.FromErrors(errors); + } + + internal ImmutableHashSet GetListContentTypes() + => _supportedContentTypes.Select(x => x.ContentType).ToImmutableHashSet(); + + internal ImmutableHashSet GetPublishContentTypes() + => _supportedContentTypes.Select(x => x.PublishType).ToImmutableHashSet(); + + internal List ValidateFilterContentTypes(ImmutableHashSet listContentTypes) + { + var errors = new List(); foreach (var filterType in Filters.ByContentType()) { - if (!listContentTypes.Contains(filterType.Key)) + if (listContentTypes.Contains(filterType.Key)) { - errors.Add(new(_localizer[SharedResourceKeys.UnknownFilterContentTypeValidationMessage, - filterType.Key.Name, filterType.Value.Count()])); + continue; } + + errors.Add(new(_localizer[SharedResourceKeys.UnknownFilterContentTypeValidationMessage, + filterType.Key.Name, filterType.Value.Count()])); } + return errors; + } + + internal List ValidateMappingContentTypes(ImmutableHashSet listContentTypes) + { + var errors = new List(); foreach (var mappingType in Mappings.ByContentType()) { - if (!listContentTypes.Contains(mappingType.Key)) + if (listContentTypes.Contains(mappingType.Key)) { - errors.Add(new(_localizer[SharedResourceKeys.UnknownMappingContentTypeValidationMessage, - mappingType.Key.Name, mappingType.Value.Count()])); + continue; } + + errors.Add(new(_localizer[SharedResourceKeys.UnknownMappingContentTypeValidationMessage, + mappingType.Key.Name, mappingType.Value.Count()])); } + return errors; + } + + internal List ValidateTransformerContentTypes( + ImmutableHashSet listContentTypes, + ImmutableHashSet publishContentTypes) + { + var errors = new List(); foreach (var transformerType in Transformers.ByContentType()) { - if (!publishContentTypes.Contains(transformerType.Key)) + if (publishContentTypes.Contains(transformerType.Key)) { - //If the user gave us a list content type instead of a publish type - //give the user a validation error with a hint to the right type. - string errorMessage; - if (listContentTypes.Contains(transformerType.Key)) - { - var hintType = _supportedContentTypes.First(x => x.ContentType == transformerType.Key).PublishType; - - errorMessage = _localizer[SharedResourceKeys.UnknownMappingContentTypeValidationMessage, - transformerType.Key.Name, hintType.Name, transformerType.Value.Count()]; - } - else - { - errorMessage = _localizer[SharedResourceKeys.UnknownMappingContentTypeValidationMessage, - transformerType.Key.Name, transformerType.Value.Count()]; - } - - errors.Add(new(errorMessage)); + continue; } + + //If the user gave us a list content type instead of a publish type + //give the user a validation error with a hint to the right type. + if (listContentTypes.Contains(transformerType.Key)) + { + var hintType = _supportedContentTypes.First(x + => x.ContentType == transformerType.Key) + .PublishType; + + errors.Add(new(_localizer[ + SharedResourceKeys.UnknownTransformerContentTypeValidationMessage, + transformerType.Key.Name, + hintType.Name, + transformerType.Value.Count()])); + + continue; + } + + errors.Add(new(_localizer[ + SharedResourceKeys.UnknownTransformerContentTypeValidationMessage, + transformerType.Key.Name, + transformerType.Value.Count()])); } - return Result.FromErrors(errors); + return errors; } /// @@ -287,15 +372,17 @@ public IResult Validate() } /// - public IMigrationPlan Build() => - new MigrationPlan(PlanId: Guid.NewGuid(), - PipelineProfile: _pipelineProfile, - Options: Options.Build(), - Source: _source, - Destination: _destination, - Hooks: Hooks.Build(), - Mappings: Mappings.Build(), - Filters: Filters.Build(), - Transformers: Transformers.Build()); + public IMigrationPlan Build() + => new MigrationPlan( + PlanId: Guid.NewGuid(), + PipelineProfile: _pipelineProfile, + Options: Options.Build(), + Source: _source, + Destination: _destination, + Hooks: Hooks.Build(), + Mappings: Mappings.Build(), + Filters: Filters.Build(), + Transformers: Transformers.Build(), + PipelineFactoryOverride: _pipelineFactoryOverride); } } diff --git a/src/Tableau.Migration/Engine/Pipelines/PipelineProfileExtensions.cs b/src/Tableau.Migration/Engine/MigrationPlanBuilderFactory.cs similarity index 57% rename from src/Tableau.Migration/Engine/Pipelines/PipelineProfileExtensions.cs rename to src/Tableau.Migration/Engine/MigrationPlanBuilderFactory.cs index 2bd53ef..342289c 100644 --- a/src/Tableau.Migration/Engine/Pipelines/PipelineProfileExtensions.cs +++ b/src/Tableau.Migration/Engine/MigrationPlanBuilderFactory.cs @@ -16,17 +16,20 @@ // using System; -using System.Collections.Immutable; +using Microsoft.Extensions.DependencyInjection; -namespace Tableau.Migration.Engine.Pipelines +namespace Tableau.Migration.Engine { - internal static class PipelineProfileExtensions + internal sealed class MigrationPlanBuilderFactory : IMigrationPlanBuilderFactory { - public static ImmutableArray GetSupportedContentTypes(this PipelineProfile profile) - => profile switch - { - PipelineProfile.ServerToCloud => ServerToCloudMigrationPipeline.ContentTypes, - _ => throw new ArgumentException($"The profile {profile} is not supported", nameof(profile)), - }; + private readonly IServiceProvider _services; + + public MigrationPlanBuilderFactory(IServiceProvider services) + { + _services = services; + } + + /// + public IMigrationPlanBuilder Create() => _services.GetRequiredService(); } } diff --git a/src/Tableau.Migration/Engine/Pipelines/CustomMigrationPipelineFactory.cs b/src/Tableau.Migration/Engine/Pipelines/CustomMigrationPipelineFactory.cs new file mode 100644 index 0000000..dee7db0 --- /dev/null +++ b/src/Tableau.Migration/Engine/Pipelines/CustomMigrationPipelineFactory.cs @@ -0,0 +1,50 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Tableau.Migration.Engine.Pipelines +{ + /// + /// implementation that can build a custom pipeline. + /// + /// The pipeline type to use. + public class CustomMigrationPipelineFactory : MigrationPipelineFactory + where TPipeline : IMigrationPipeline + { + /// + /// Creates a new object. + /// + /// A service provider to create pipelines with. + public CustomMigrationPipelineFactory(IServiceProvider services) + : base(services) + { } + + /// + public override IMigrationPipeline Create(IMigrationPlan plan) + { + switch(plan.PipelineProfile) + { + case PipelineProfile.Custom: + return Services.GetRequiredService(); + default: + return base.Create(plan); + } + } + } +} diff --git a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs index 313d37a..11fbe8a 100644 --- a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs +++ b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineContentType.cs @@ -20,7 +20,6 @@ using System.Linq; using System.Text; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; @@ -74,6 +73,12 @@ public record MigrationPipelineContentType(Type ContentType) public static readonly MigrationPipelineContentType ServerToCloudExtractRefreshTasks = new MigrationPipelineContentType() .WithPublishType(); + /// + /// Gets the custom views . + /// + public static readonly MigrationPipelineContentType CustomViews = new MigrationPipelineContentType() + .WithPublishType(); + /// /// Gets the publish type. /// @@ -120,42 +125,50 @@ public MigrationPipelineContentType WithResultType() /// /// The interface to search for. public Type[]? GetPublishTypeForInterface(Type @interface) - => HasInterface(PublishType, @interface) ? new[] { PublishType } : null; + => HasInterface(PublishType, @interface) ? [PublishType] : null; /// /// Gets the value if it implements the given interface, or null if it does not. /// /// The interface to search for. public Type[]? GetContentTypeForInterface(Type @interface) - => HasInterface(ContentType, @interface) ? new[] { ContentType } : null; + => HasInterface(ContentType, @interface) ? [ContentType] : null; /// /// Gets the and array if it implements the given interface, or null if it does not. /// /// The interface to search for. public Type[]? GetPostPublishTypesForInterface(Type @interface) - => HasInterface(PublishType, @interface) ? new[] { PublishType, ResultType } : null; + => HasInterface(PublishType, @interface) ? [PublishType, ResultType] : null; /// /// Gets the config key for this content type. /// /// The config key string. public string GetConfigKey() + => GetConfigKeyForType(ContentType); + + /// + /// Gets the config key for a content type. + /// + /// The content type. + /// The config key string. + public static string GetConfigKeyForType(Type contentType) { - if (!ContentType.IsGenericType) + if (!contentType.IsGenericType) { - var typeName = ContentType.Name; + var typeName = contentType.Name; return typeName.TrimStart('I'); } var convertedName = new StringBuilder() .Append( - ContentType.Name + contentType.Name .TrimStart('I') .TrimEnd('1') .TrimEnd('`')); - foreach (var arg in ContentType.GenericTypeArguments) + foreach (var arg in contentType.GenericTypeArguments) { convertedName.Append($"_{arg.Name.TrimStart('I')}"); } diff --git a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs index aa72243..cb26f76 100644 --- a/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs +++ b/src/Tableau.Migration/Engine/Pipelines/MigrationPipelineFactory.cs @@ -25,7 +25,10 @@ namespace Tableau.Migration.Engine.Pipelines /// public class MigrationPipelineFactory : IMigrationPipelineFactory { - private readonly IServiceProvider _services; + /// + /// Gets the migration-scoped service provider. + /// + protected IServiceProvider Services { get; } /// /// Creates a new object. @@ -33,16 +36,16 @@ public class MigrationPipelineFactory : IMigrationPipelineFactory /// A service provider to create pipelines with. public MigrationPipelineFactory(IServiceProvider services) { - _services = services; + Services = services; } /// - public IMigrationPipeline Create(IMigrationPlan plan) + public virtual IMigrationPipeline Create(IMigrationPlan plan) { switch (plan.PipelineProfile) { case PipelineProfile.ServerToCloud: - return _services.GetRequiredService(); + return Services.GetRequiredService(); default: throw new ArgumentException($"Cannot create a migration pipeline for profile {plan.PipelineProfile}"); } diff --git a/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs b/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs index a6d207c..259782e 100644 --- a/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs +++ b/src/Tableau.Migration/Engine/Pipelines/ServerToCloudMigrationPipeline.cs @@ -21,7 +21,6 @@ using Microsoft.Extensions.DependencyInjection; using Tableau.Migration.Config; using Tableau.Migration.Content; -using Tableau.Migration.Content.Schedules; using Tableau.Migration.Content.Schedules.Cloud; using Tableau.Migration.Content.Schedules.Server; using Tableau.Migration.Engine.Actions; @@ -44,7 +43,8 @@ public class ServerToCloudMigrationPipeline : MigrationPipelineBase MigrationPipelineContentType.Projects, MigrationPipelineContentType.DataSources, MigrationPipelineContentType.Workbooks, - MigrationPipelineContentType.ServerToCloudExtractRefreshTasks + MigrationPipelineContentType.ServerToCloudExtractRefreshTasks, + MigrationPipelineContentType.CustomViews ]; private readonly IConfigReader _configReader; @@ -77,6 +77,7 @@ protected override IEnumerable BuildPipeline() yield return CreateMigrateContentAction(); yield return CreateMigrateContentAction(); yield return CreateMigrateContentAction(); + yield return CreateMigrateContentAction(); } /// @@ -96,10 +97,12 @@ public override IContentBatchMigrator GetBatchMigrator() return Services.GetRequiredService>(); case Type dataSource when dataSource == typeof(IDataSource): return Services.GetRequiredService>(); - case Type worbook when worbook == typeof(IWorkbook): + case Type workbook when workbook == typeof(IWorkbook): return Services.GetRequiredService>(); case Type extractRefreshTask when extractRefreshTask == typeof(IServerExtractRefreshTask): return Services.GetRequiredService>(); + case Type customView when customView == typeof(ICustomView): + return Services.GetRequiredService>(); default: return base.GetBatchMigrator(); } diff --git a/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs b/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs index f3a5ec5..51b8c64 100644 --- a/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs +++ b/src/Tableau.Migration/Engine/ServerToCloudMigrationPlanBuilder.cs @@ -31,6 +31,7 @@ using Tableau.Migration.Engine.Hooks.Transformers; using Tableau.Migration.Engine.Hooks.Transformers.Default; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Engine.Pipelines; using Tableau.Migration.Resources; namespace Tableau.Migration.Engine @@ -81,6 +82,24 @@ IMigrationPlanBuilder IMigrationPlanBuilder.AppendDefaultExtensions() IServerToCloudMigrationPlanBuilder IMigrationPlanBuilder.ForServerToCloud() => _innerBuilder.ForServerToCloud(); + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(Func pipelineFactoryOverride, params MigrationPipelineContentType[] supportedContentTypes) + => _innerBuilder.ForCustomPipelineFactory(pipelineFactoryOverride, supportedContentTypes); + + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(Func pipelineFactoryOverride, IEnumerable supportedContentTypes) + => _innerBuilder.ForCustomPipelineFactory(pipelineFactoryOverride, supportedContentTypes); + + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(params MigrationPipelineContentType[] supportedContentTypes) + => _innerBuilder.ForCustomPipelineFactory(supportedContentTypes); + + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipelineFactory(IEnumerable supportedContentTypes) + => _innerBuilder.ForCustomPipelineFactory(supportedContentTypes); + + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipeline(params MigrationPipelineContentType[] supportedContentTypes) + => _innerBuilder.ForCustomPipeline(supportedContentTypes); + + IMigrationPlanBuilder IMigrationPlanBuilder.ForCustomPipeline(IEnumerable supportedContentTypes) + => _innerBuilder.ForCustomPipeline(supportedContentTypes); + IMigrationPlanBuilder IMigrationPlanBuilder.FromSource(IMigrationPlanEndpointConfiguration config) => _innerBuilder.FromSource(config); diff --git a/src/Tableau.Migration/IMigrationPlan.cs b/src/Tableau.Migration/IMigrationPlan.cs index ae295ba..7e220fe 100644 --- a/src/Tableau.Migration/IMigrationPlan.cs +++ b/src/Tableau.Migration/IMigrationPlan.cs @@ -19,6 +19,7 @@ using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Hooks; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Engine.Pipelines; namespace Tableau.Migration { @@ -71,5 +72,10 @@ public interface IMigrationPlan /// Gets the collection of registered transformers for each content type. /// IMigrationHookFactoryCollection Transformers { get; } + + /// + /// Gets the pipeline factory to use to create the pipeline during migration. + /// + Func? PipelineFactoryOverride { get; } } } diff --git a/src/Tableau.Migration/IMigrationPlanBuilder.cs b/src/Tableau.Migration/IMigrationPlanBuilder.cs index 9801ddd..95069a9 100644 --- a/src/Tableau.Migration/IMigrationPlanBuilder.cs +++ b/src/Tableau.Migration/IMigrationPlanBuilder.cs @@ -16,12 +16,14 @@ // using System; +using System.Collections.Generic; using Tableau.Migration.Engine.Endpoints; using Tableau.Migration.Engine.Hooks; using Tableau.Migration.Engine.Hooks.Filters; using Tableau.Migration.Engine.Hooks.Mappings; using Tableau.Migration.Engine.Hooks.Transformers; using Tableau.Migration.Engine.Options; +using Tableau.Migration.Engine.Pipelines; namespace Tableau.Migration { @@ -36,6 +38,54 @@ public interface IMigrationPlanBuilder /// The same plan builder object for fluent API calls. IServerToCloudMigrationPlanBuilder ForServerToCloud(); + /// + /// Initializes the plan to perform a custom migration pipeline using the given pipeline factory. + /// + /// An initializer function to build the pipeline factory. + /// The supported content types of the custom pipeline. + /// The same plan builder object for fluent API calls. + IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, params MigrationPipelineContentType[] supportedContentTypes); + + /// + /// Initializes the plan to perform a custom migration pipeline using the given pipeline factory. + /// + /// An initializer function to build the pipeline factory. + /// The supported content types of the custom pipeline. + /// The same plan builder object for fluent API calls. + IMigrationPlanBuilder ForCustomPipelineFactory(Func pipelineFactoryOverride, IEnumerable supportedContentTypes); + + /// + /// Initializes the plan to perform a custom migration pipeline using the given pipeline factory. + /// + /// The supported content types of the custom pipeline. + /// The same plan builder object for fluent API calls. + IMigrationPlanBuilder ForCustomPipelineFactory(params MigrationPipelineContentType[] supportedContentTypes) + where T : IMigrationPipelineFactory; + + /// + /// Initializes the plan to perform a custom migration pipeline using the given pipeline factory. + /// + /// The supported content types of the custom pipeline. + /// The same plan builder object for fluent API calls. + IMigrationPlanBuilder ForCustomPipelineFactory(IEnumerable supportedContentTypes) + where T : IMigrationPipelineFactory; + + /// + /// Initializes the plan to perform a custom migration pipeline. + /// + /// The supported content types of the custom pipeline. + /// The same plan builder object for fluent API calls. + IMigrationPlanBuilder ForCustomPipeline(params MigrationPipelineContentType[] supportedContentTypes) + where T : IMigrationPipeline; + + /// + /// Initializes the plan to perform a custom migration pipeline. + /// + /// The supported content types of the custom pipeline. + /// The same plan builder object for fluent API calls. + IMigrationPlanBuilder ForCustomPipeline(IEnumerable supportedContentTypes) + where T : IMigrationPipeline; + /// /// Clears all hooks, filters, mappings, and transformations. /// diff --git a/src/Tableau.Migration/IUserAgentSuffixProvider.cs b/src/Tableau.Migration/IMigrationPlanBuilderFactory.cs similarity index 67% rename from src/Tableau.Migration/IUserAgentSuffixProvider.cs rename to src/Tableau.Migration/IMigrationPlanBuilderFactory.cs index 945ea62..d9d9bf4 100644 --- a/src/Tableau.Migration/IUserAgentSuffixProvider.cs +++ b/src/Tableau.Migration/IMigrationPlanBuilderFactory.cs @@ -18,10 +18,14 @@ namespace Tableau.Migration { /// - /// Interface for an object that builds the user agent suffix + /// Interface for an object that can create objects. /// - internal interface IUserAgentSuffixProvider + public interface IMigrationPlanBuilderFactory { - public string UserAgentSuffix { get; init; } + /// + /// Creates a new object. + /// + /// The newly created plan builder. + IMigrationPlanBuilder Create(); } -} +} \ No newline at end of file diff --git a/src/Tableau.Migration/IMigrationSdk.cs b/src/Tableau.Migration/IMigrationSdk.cs index cadb2f3..8cb6cd5 100644 --- a/src/Tableau.Migration/IMigrationSdk.cs +++ b/src/Tableau.Migration/IMigrationSdk.cs @@ -20,18 +20,13 @@ namespace Tableau.Migration { /// - /// Abstraction responsible to return the current SDK version and user agent string, based on the Executing Assembly Version. + /// Interface for global SDK metadata. /// internal interface IMigrationSdk { /// - /// The current SDK Version + /// Gets the current SDK version. /// - /// The current SDK version. Version Version { get; } - /// - /// Identifier string for the SDK user-agent. - /// - string UserAgent { get; } } } diff --git a/src/Tableau.Migration/IServiceCollectionExtensions.cs b/src/Tableau.Migration/IServiceCollectionExtensions.cs index 1e14a75..cf3ca5e 100644 --- a/src/Tableau.Migration/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/IServiceCollectionExtensions.cs @@ -18,10 +18,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; using Tableau.Migration.Api; using Tableau.Migration.Config; using Tableau.Migration.Engine; -using Tableau.Migration.Engine.Manifest; using Tableau.Migration.Net; using Tableau.Migration.Resources; @@ -36,8 +36,8 @@ internal static IServiceCollection AddSharedResourcesLocalization(this IServiceC { //Register localization. services - .AddLogging() //required for AddLocalization, users can customize afterwards by re-calling. - .AddLocalization() //required for our shared resource localizer, users can customize afterwards by re-calling. + .AddLogging() //Required for AddLocalization, users can customize afterwards by re-calling. + .AddLocalization() //Required for our shared resource localizer, users can customize afterwards by re-calling. .AddTransient(provider => { var localizerFactory = provider.GetRequiredService(); @@ -48,19 +48,37 @@ internal static IServiceCollection AddSharedResourcesLocalization(this IServiceC return services; } + internal static IServiceCollection AddMigrationSdkConfiguration(this IServiceCollection services, IConfiguration config) + { + services.Configure(nameof(MigrationSdkOptions), config); + services.AddSingleton, UniqueContentTypesValidator>(); + + /* + * Eager validate configuration. + * We want configuration exceptions to happen before any migration work, + * so we validate here instead of the first time IConfigReader.Get is called in the migration. + * We also don't use the built-in ValidateOnStart since that relies on .NET hosted applications running the validation, + * which doesn't cover all our use cases (Python/etc.). + */ + var options = services.BuildServiceProvider().GetRequiredService().Get(); + + return services; + } + /// /// Registers services required for using the Tableau Migration SDK. /// /// The service collection to register services with. - /// The configuration options to initialize the SDK with + /// The configuration options to initialize the SDK with. /// The same service collection as the parameter. - public static IServiceCollection AddTableauMigrationSdk(this IServiceCollection services, IConfiguration? userOptions = null) + public static IServiceCollection AddTableauMigrationSdk(this IServiceCollection services, + IConfiguration? userOptions = null) { services.AddHttpServices(); if (userOptions is not null) { - services.Configure(nameof(MigrationSdkOptions), userOptions); + services.AddMigrationSdkConfiguration(userOptions); } services.AddMigrationApiClient() diff --git a/src/Tableau.Migration/Interop/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Interop/IServiceCollectionExtensions.cs index 48d71e6..5b559c6 100644 --- a/src/Tableau.Migration/Interop/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Interop/IServiceCollectionExtensions.cs @@ -20,7 +20,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; -using Tableau.Migration.Config; using Tableau.Migration.Interop.Logging; namespace Tableau.Migration.Interop @@ -36,22 +35,18 @@ public static class IServiceCollectionExtensions /// /// The service collection to register services with. /// A factory to use to create new loggers for a given category name. - /// The configuration options to initialize the SDK with. /// public static IServiceCollection AddPythonSupport( this IServiceCollection services, - Func loggerFactory, - IConfiguration? userOptions = null) + Func loggerFactory) { - services + // Add environment variable configuration. + var userOptions = BuildEnvironmentVariableConfiguration(); + + return services .AddTableauMigrationSdk(userOptions) - // Replace the default IUserAgentSuffixProvider with the python one - .Replace(new ServiceDescriptor(typeof(IUserAgentSuffixProvider), typeof(PythonUserAgentSuffixProvider), ServiceLifetime.Singleton)) - // Add Python Logging - .AddLogging(b => b.AddPythonLogging(loggerFactory)) - // Add environment variable configuration - .AddEnvironmentVariableConfiguration(); - return services; + // Add Python logging. + .AddLogging(b => b.AddPythonLogging(loggerFactory)); } /// @@ -78,18 +73,17 @@ public static ILoggingBuilder AddPythonLogging( /// Adds support for setting configuration values via environment variables. /// Environment variables start with "MigrationSDK__". /// - /// - /// - public static IServiceCollection AddEnvironmentVariableConfiguration(this IServiceCollection services) + /// The build configuration. + private static IConfiguration BuildEnvironmentVariableConfiguration() { - var configBuilder = - new ConfigurationBuilder() - .AddEnvironmentVariables("MigrationSDK__"); - var config = configBuilder.Build(); - - services.Configure(nameof(MigrationSdkOptions), config); + // Set standard python configuration values. + Environment.SetEnvironmentVariable(Constants.PYTHON_USER_AGENT_COMMENT_CONFIG_KEY, Constants.PYTHON_USER_AGENT_COMMENT); - return services; + var configBuilder = new ConfigurationBuilder() + .AddEnvironmentVariables(Constants.PYTHON_ENVIRONMENT_VARIABLE_PREFIX); + + var config = configBuilder.Build(); + return config; } } } diff --git a/src/Tableau.Migration/JsonConverters/FailedJobExceptionJsonConverter.cs b/src/Tableau.Migration/JsonConverters/FailedJobExceptionJsonConverter.cs index b3ba354..2459b9b 100644 --- a/src/Tableau.Migration/JsonConverters/FailedJobExceptionJsonConverter.cs +++ b/src/Tableau.Migration/JsonConverters/FailedJobExceptionJsonConverter.cs @@ -1,3 +1,20 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + using System; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableContentLocation.cs b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableContentLocation.cs index b9fe5e5..685375e 100644 --- a/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableContentLocation.cs +++ b/src/Tableau.Migration/JsonConverters/SerializableObjects/SerializableContentLocation.cs @@ -62,7 +62,7 @@ public SerializableContentLocation() { } /// The content location to serialize. internal SerializableContentLocation(ContentLocation location) { - PathSegments = location.PathSegments.ToArray(); + PathSegments = location.PathSegments == null ? [] : [.. location.PathSegments]; PathSeparator = location.PathSeparator; Path = location.Path; Name = location.Name; diff --git a/src/Tableau.Migration/MigrationSdk.cs b/src/Tableau.Migration/MigrationSdk.cs index aecbf53..b9fbb63 100644 --- a/src/Tableau.Migration/MigrationSdk.cs +++ b/src/Tableau.Migration/MigrationSdk.cs @@ -21,31 +21,22 @@ namespace Tableau.Migration { /// - /// Implementation responsible to return the current SDK version and user agent string, based on the Executing Assembly Version. + /// Default implementation. /// internal sealed class MigrationSdk : IMigrationSdk { - private readonly IUserAgentSuffixProvider _userAgentSuffixProvider; - /// - /// Sets the properties on initialization so they are immutable. + /// Creates a new object. /// - public MigrationSdk(IUserAgentSuffixProvider userAgentSuffixProvider) + public MigrationSdk() { Version = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(); - _userAgentSuffixProvider = userAgentSuffixProvider; - - UserAgent = $"{Constants.USER_AGENT_PREFIX}{_userAgentSuffixProvider.UserAgentSuffix}/{Version}"; } + /// /// The current SDK Version /// /// The current SDK version. public Version Version { get; init; } - /// - /// Identifier string for the SDK user-agent. - /// - - public string UserAgent { get; init; } } } diff --git a/src/Tableau.Migration/NameOf.cs b/src/Tableau.Migration/NameOf.cs index 7f8599b..39da6ab 100644 --- a/src/Tableau.Migration/NameOf.cs +++ b/src/Tableau.Migration/NameOf.cs @@ -35,9 +35,21 @@ internal static class NameOf /// /// /// The expression to generate the member chain string for. - /// + /// A string representing the specified expression. public static string Build(Expression> expression) => Build(expression.Body); + /// + /// + /// Builds the member chain string for the given expression. + /// + /// + /// For example, given the expression (x) => x.MyInnerObject.MyProperty, the string "MyInnerObject.MyProperty" will be returned. + /// + /// + /// The expression to generate the member chain string for. + /// A string representing the specified expression. + public static string Build(Expression> expression) => Build(expression.Body); + private static string Build(Expression? expression) { // Handle reference types diff --git a/src/Tableau.Migration/Net/Handlers/UserAgentHttpMessageHandler.cs b/src/Tableau.Migration/Net/Handlers/UserAgentHttpMessageHandler.cs index 4cb7a3f..3a69a45 100644 --- a/src/Tableau.Migration/Net/Handlers/UserAgentHttpMessageHandler.cs +++ b/src/Tableau.Migration/Net/Handlers/UserAgentHttpMessageHandler.cs @@ -26,14 +26,17 @@ namespace Tableau.Migration.Net.Handlers /// internal class UserAgentHttpMessageHandler : DelegatingHandler { - private readonly string _userAgent; + private readonly IUserAgentProvider _userAgentProvider; - public UserAgentHttpMessageHandler(IMigrationSdk sdk) => _userAgent = sdk.UserAgent; + public UserAgentHttpMessageHandler(IUserAgentProvider userAgentProvider) + { + _userAgentProvider = userAgentProvider; + } protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { request.Headers.UserAgent.Clear(); - request.Headers.UserAgent.TryParseAdd(_userAgent); + request.Headers.UserAgent.TryParseAdd(_userAgentProvider.UserAgent); return base.SendAsync(request, cancellationToken); } diff --git a/src/Tableau.Migration/Net/HttpContentExtensions.cs b/src/Tableau.Migration/Net/HttpContentExtensions.cs index fd87107..afa26af 100644 --- a/src/Tableau.Migration/Net/HttpContentExtensions.cs +++ b/src/Tableau.Migration/Net/HttpContentExtensions.cs @@ -28,6 +28,9 @@ internal static class HttpContentExtensions internal static bool IsUtf8Content(this HttpContent content) => content.Headers.ContentType?.IsUtf8() is true; + internal static bool IsHtmlContent(this HttpContent content) + => content.Headers.ContentType?.IsHtml() is true; + internal static bool IsXmlContent(this HttpContent content) => content.Headers.ContentType?.IsXml() is true; diff --git a/src/Tableau.Migration/Net/HttpContentSerializer.cs b/src/Tableau.Migration/Net/HttpContentSerializer.cs index 99c9d2e..8a8070f 100644 --- a/src/Tableau.Migration/Net/HttpContentSerializer.cs +++ b/src/Tableau.Migration/Net/HttpContentSerializer.cs @@ -40,6 +40,17 @@ public HttpContentSerializer(ITableauSerializer serializer) { var data = default(T); + /* Tableau APIs sometimes return HTML error pages. + * We consider those deserialization failures, + * but want to include the HTML content in the exception + * so users can check it for debugging purposes. + */ + if(content.IsHtmlContent()) + { + var htmlContent = await content.ReadAsEncodedStringAsync(cancel).ConfigureAwait(false); + throw new FormatException("Server responded with HTML error page: " + htmlContent); + } + if (content.IsXmlContent()) { var stringContent = await content.ReadAsEncodedStringAsync(cancel).ConfigureAwait(false); @@ -69,7 +80,7 @@ public HttpContentSerializer(ITableauSerializer serializer) } else { - throw new NotSupportedException($"Content Type {content.Headers.ContentType?.MediaType ?? ""} not supported"); + throw new NotSupportedException($"Content Type {content.Headers.ContentType?.MediaType ?? ""} not supported."); } return data; diff --git a/src/Tableau.Migration/Net/IRequestBuilderFactory.cs b/src/Tableau.Migration/Net/IRequestBuilderFactory.cs index c2706ed..a15a4d2 100644 --- a/src/Tableau.Migration/Net/IRequestBuilderFactory.cs +++ b/src/Tableau.Migration/Net/IRequestBuilderFactory.cs @@ -26,8 +26,9 @@ public interface IRequestBuilderFactory /// Creates a new instance. /// /// The URI path. + /// Flag indicating if the experimental API Version should be used. /// A new instance. - IRequestBuilder CreateUri(string path); + IRequestBuilder CreateUri(string path, bool useExperimental = false); } /// @@ -40,7 +41,8 @@ public interface IRequestBuilderFactory : IRequestBuilderFactor /// Creates a new instance. /// /// The URI path. + /// Flag indicating if the experimental API Version should be used. /// A new instance. - new TRequestBuilder CreateUri(string path); + new TRequestBuilder CreateUri(string path, bool useExperimental = false); } } diff --git a/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs b/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs index 619e36c..3b9d87e 100644 --- a/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs +++ b/src/Tableau.Migration/Net/IServiceCollectionExtensions.cs @@ -41,8 +41,7 @@ internal static class IServiceCollectionExtensions /// /// The to add the service to. /// A reference to this instance after the operation has completed. - internal static IServiceCollection AddHttpServices( - this IServiceCollection services) + internal static IServiceCollection AddHttpServices(this IServiceCollection services) { services.AddSharedResourcesLocalization(); @@ -51,9 +50,9 @@ internal static IServiceCollection AddHttpServices( services.AddSingleton(); services - .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddTransient() diff --git a/src/Tableau.Migration/UserAgentSuffixProvider.cs b/src/Tableau.Migration/Net/IUserAgentProvider.cs similarity index 73% rename from src/Tableau.Migration/UserAgentSuffixProvider.cs rename to src/Tableau.Migration/Net/IUserAgentProvider.cs index 2d75fe0..d536c32 100644 --- a/src/Tableau.Migration/UserAgentSuffixProvider.cs +++ b/src/Tableau.Migration/Net/IUserAgentProvider.cs @@ -15,13 +15,16 @@ // limitations under the License. // -namespace Tableau.Migration +namespace Tableau.Migration.Net { - internal class UserAgentSuffixProvider : IUserAgentSuffixProvider + /// + /// Interface for an object that can provide a user agent string. + /// + internal interface IUserAgentProvider { /// - /// The default User agent suffix is empty. + /// Gets the user agent string. /// - public string UserAgentSuffix { get; init; } = string.Empty; + string UserAgent { get; } } } diff --git a/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs b/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs index 3269bc3..bdfa436 100644 --- a/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs +++ b/src/Tableau.Migration/Net/MediaTypeHeaderValueExtensions.cs @@ -64,6 +64,8 @@ internal static bool IsUtf8(this MediaTypeHeaderValue header) return false; } + internal static bool IsHtml(this MediaTypeHeaderValue header) => IsMediaType(header, MediaTypeNames.Text.Html); + internal static bool IsJson(this MediaTypeHeaderValue header) => IsMediaType(header, MediaTypeNames.Application.Json); internal static bool IsXml(this MediaTypeHeaderValue header) => IsMediaType(header, MediaTypeNames.Application.Xml); diff --git a/src/Tableau.Migration/Net/RequestBuilderFactory.cs b/src/Tableau.Migration/Net/RequestBuilderFactory.cs index c396718..ccea232 100644 --- a/src/Tableau.Migration/Net/RequestBuilderFactory.cs +++ b/src/Tableau.Migration/Net/RequestBuilderFactory.cs @@ -39,9 +39,9 @@ public RequestBuilderFactory(IRequestBuilderFactoryInput input) } /// - public abstract TRequestBuilder CreateUri(string path); + public abstract TRequestBuilder CreateUri(string path, bool useExperimental = false); /// - IRequestBuilder IRequestBuilderFactory.CreateUri(string path) => CreateUri(path); + IRequestBuilder IRequestBuilderFactory.CreateUri(string path, bool useExperimental) => CreateUri(path, useExperimental); } } diff --git a/src/Tableau.Migration/Net/Rest/IRestRequestBuilderFactory.cs b/src/Tableau.Migration/Net/Rest/IRestRequestBuilderFactory.cs index 3bc36fc..56796ad 100644 --- a/src/Tableau.Migration/Net/Rest/IRestRequestBuilderFactory.cs +++ b/src/Tableau.Migration/Net/Rest/IRestRequestBuilderFactory.cs @@ -15,31 +15,11 @@ // limitations under the License. // -using System; - namespace Tableau.Migration.Net.Rest { /// /// Interface for factories. /// public interface IRestRequestBuilderFactory : IRequestBuilderFactory - { - /// - /// Sets the default API version to use when creating instances. - /// - /// The API version. - void SetDefaultApiVersion(string? version); - - /// - /// Sets the default site ID to use when creating instances. - /// - /// The site ID. - void SetDefaultSiteId(Guid? siteId); - - /// - /// Sets the default site ID to use when creating instances. - /// - /// The site ID. - void SetDefaultSiteId(string? siteId); - } + { } } \ No newline at end of file diff --git a/src/Tableau.Migration/Net/Rest/RestRequestBuilderFactory.cs b/src/Tableau.Migration/Net/Rest/RestRequestBuilderFactory.cs index ea00895..56ba99e 100644 --- a/src/Tableau.Migration/Net/Rest/RestRequestBuilderFactory.cs +++ b/src/Tableau.Migration/Net/Rest/RestRequestBuilderFactory.cs @@ -25,9 +25,6 @@ internal sealed class RestRequestBuilderFactory : RequestBuilderFactory _apiVersion = version; - - public void SetDefaultSiteId(Guid? siteId) => SetDefaultSiteId(siteId?.ToUrlSegment()); + private string? GetApiVersion() + => _sessionProvider.Version?.RestApiVersion; - public void SetDefaultSiteId(string? siteId) => _siteId = siteId; + private string? GetSiteId() + => _sessionProvider.SiteId?.ToUrlSegment(); - public override IRestRequestBuilder CreateUri(string path) + public override IRestRequestBuilder CreateUri(string path, bool useExperimental = false) { var builder = new RestRequestBuilder(BaseUri, path, _requestBuilderFactory); - if (_apiVersion is null && _sessionProvider.Version?.RestApiVersion is not null) - _apiVersion = _sessionProvider.Version?.RestApiVersion; - - if (_siteId is null && _sessionProvider.SiteId is not null) - _siteId = _sessionProvider.SiteId.Value.ToUrlSegment(); - - if (_apiVersion is not null) - builder.WithApiVersion(_apiVersion); + if (useExperimental) + { + builder.WithApiVersion(ApiClient.EXPERIMENTAL_API_VERSION); + } + else + { + var apiVersion = GetApiVersion(); + if(apiVersion is not null) + { + builder.WithApiVersion(apiVersion); + } + } - if (_siteId is not null) - builder.WithSiteId(_siteId); + var siteId = GetSiteId(); + if (siteId is not null) + builder.WithSiteId(siteId); return builder; } diff --git a/src/Tableau.Migration/Net/UserAgentProvider.cs b/src/Tableau.Migration/Net/UserAgentProvider.cs new file mode 100644 index 0000000..cd32feb --- /dev/null +++ b/src/Tableau.Migration/Net/UserAgentProvider.cs @@ -0,0 +1,46 @@ +// +// Copyright (c) 2024, Salesforce, Inc. +// SPDX-License-Identifier: Apache-2 +// +// Licensed under the Apache License, Version 2.0 (the "License") +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +using System.Text; +using Tableau.Migration.Config; + +namespace Tableau.Migration.Net +{ + /// + /// Default implementation. + /// + internal sealed class UserAgentProvider : IUserAgentProvider + { + public UserAgentProvider(IMigrationSdk sdk, IConfigReader config) + { + var userAgentBuilder = new StringBuilder(Constants.USER_AGENT_PREFIX); + userAgentBuilder.Append("/"); + userAgentBuilder.Append(sdk.Version); + + var comments = config.Get().Network.UserAgentComment; + if (!string.IsNullOrWhiteSpace(comments)) + { + userAgentBuilder.AppendFormat(" ({0})", comments); + } + + UserAgent = userAgentBuilder.ToString(); + } + + /// + public string UserAgent { get; } + } +} diff --git a/src/Tableau.Migration/PipelineProfile.cs b/src/Tableau.Migration/PipelineProfile.cs index 1be70c6..6889151 100644 --- a/src/Tableau.Migration/PipelineProfile.cs +++ b/src/Tableau.Migration/PipelineProfile.cs @@ -22,11 +22,14 @@ namespace Tableau.Migration /// public enum PipelineProfile { - //Custom = 0, //Uncomment when custom pipelines are supported. - /// /// The pipeline to bulk migrate content from a Tableau Server site to a Tableau Cloud site. /// - ServerToCloud = 1 + ServerToCloud = 1, + + /// + /// A custom pipeline supplied by the migration plan is used. + /// + Custom = 2 } } diff --git a/src/Tableau.Migration/Resources/SharedResourceKeys.cs b/src/Tableau.Migration/Resources/SharedResourceKeys.cs index d84f5cc..c92eb87 100644 --- a/src/Tableau.Migration/Resources/SharedResourceKeys.cs +++ b/src/Tableau.Migration/Resources/SharedResourceKeys.cs @@ -1,4 +1,4 @@ -// +// // Copyright (c) 2024, Salesforce, Inc. // SPDX-License-Identifier: Apache-2 // @@ -57,6 +57,8 @@ internal static class SharedResourceKeys public const string UnknownMappingContentTypeValidationMessage = "UnknownMappingContentTypeValidationMessage"; + public const string UnknownTransformerContentTypeValidationMessage = "UnknownTransformerContentTypeValidationMessage"; + public const string InvalidTransformerPublishTypeValidationMessage = "InvalidTransformerPublishTypeValidationMessage"; public const string UnknownTransformerPublishTypeValidationMessage = "UnknownTransformerPublishTypeValidationMessage"; @@ -69,6 +71,8 @@ internal static class SharedResourceKeys public const string SourceUserNotFoundLogMessage = "SourceUserNotFoundLogMessage"; + public const string SourceWorkbookNotFoundLogMessage = "SourceWorkbookNotFoundLogMessage"; + public const string PublishedDataSourceReferenceNotFoundLogMessage = "PublishedDataSourceReferenceNotFoundLogMessage"; public const string FailedJobExceptionContent = "FailedJobExceptionContent"; @@ -83,14 +87,14 @@ internal static class SharedResourceKeys public const string GroupUsersTransformerCannotAddUserWarning = "GroupUsersTransformerCannotAddUserWarning"; + public const string CustomViewDefaultUsersTransformerNoUserRefsDebugMessage = "CustomViewDefaultUsersTransformerNoUserRefsDebugMessage"; + public const string PermissionsTransformerGranteeNotFoundWarning = "PermissionsTransformerGranteeNotFoundWarning"; public const string SiteSettingsSkippedDisabledLogMessage = "SiteSettingsSkippedDisabledLogMessage"; public const string SiteSettingsSkippedNoAccessLogMessage = "SiteSettingsSkippedNoAccessLogMessage"; - public const string SiteSettingsExtractEncryptionDisabledLogMessage = "SiteSettingsExtractEncryptionDisabledLogMessage"; - public const string ApiClientDoesnotImplementIReadApiClientError = "ApiClientDoesnotImplementIReadApiClientError"; public const string ApiEndpointNotInitializedError = "ApiEndpointNotInitializedError"; @@ -99,8 +103,24 @@ internal static class SharedResourceKeys public const string ProjectReferenceNotFoundMessage = "ProjectReferenceNotFoundMessage"; + public const string ProjectReferenceNotFoundException = "ProjectReferenceNotFoundException"; + public const string OwnerNotFoundMessage = "OwnerNotFoundMessage"; + public const string OwnerNotFoundException = "OwnerNotFoundException"; + + public const string WorkbookReferenceNotFoundMessage = "WorkbookReferenceNotFoundMessage"; + + public const string WorkbookReferenceNotFoundException = "WorkbookReferenceNotFoundException"; + + public const string ViewReferenceNotFoundMessage = "ViewReferenceNotFoundMessage"; + + public const string ViewReferenceNotFoundException = "ViewReferenceNotFoundException"; + + public const string UserReferenceNotFoundMessage = "UserReferenceNotFoundMessage"; + + public const string UserReferenceNotFoundException = "UserReferenceNotFoundException"; + public const string FailedToGetDefaultPermissionsMessage = "FailedToGetDefaultPermissionsMessage"; public const string TableauInstanceTypeNotSupportedMessage = "TableauInstanceTypeNotSupportedMessage"; @@ -108,5 +128,19 @@ internal static class SharedResourceKeys public const string MappedReferenceExtractRefreshTaskTransformerCannotFindReferenceWarning = "MappedReferenceExtractRefreshTaskTransformerCannotFindReferenceWarning"; public const string IntervalsChangedWarning = "IntervalsChangedWarning"; + + public const string Found = "Found"; + + public const string NotFound = "NotFound"; + + public const string CustomViewSkippedMissingReferenceWarning = "CustomViewSkippedMissingReferenceWarning"; + + public const string DataSourceSkippedMissingReferenceWarning = "DataSourceSkippedMissingReferenceWarning"; + + public const string WorkbookSkippedMissingReferenceWarning = "WorkbookSkippedMissingReferenceWarning"; + + public const string UserWithCustomViewDefaultSkippedMissingReferenceWarning = "UserWithCustomViewDefaultSkippedMissingReferenceWarning"; + + public const string DuplicateContentTypeConfigurationMessage = "DuplicateContentTypeConfigurationMessage"; } } diff --git a/src/Tableau.Migration/Resources/SharedResources.resx b/src/Tableau.Migration/Resources/SharedResources.resx index 4dfd1cd..9ce88d8 100644 --- a/src/Tableau.Migration/Resources/SharedResources.resx +++ b/src/Tableau.Migration/Resources/SharedResources.resx @@ -1,4 +1,4 @@ - +