From 313824380dcc7bbfefcb614465adfc6a67207e9e Mon Sep 17 00:00:00 2001 From: Andrew Leverette Date: Wed, 15 Jul 2026 10:33:33 -0500 Subject: [PATCH 1/4] feat(googleSheets): add google sheets integration --- Build/Build.bat | 4 +- Components/Services/DelineaClient.cs | 177 +++++++++++++ .../GoogleSheets/GoogleSheetsClient.cs | 241 ++++++++++++++++++ .../GoogleSheetsClientException.cs | 54 ++++ DNNStuff.SQLViewPro.csproj | 24 +- DNNStuff.SQLViewPro.sln | 6 + ...NNStuff.SQLViewPro.MobileParameters.csproj | 2 +- ...Stuff.SQLViewPro.StandardParameters.csproj | 2 +- ...DNNStuff.SQLViewPro.SqlDataProvider.csproj | 2 +- .../DNNStuff.SQLViewPro.ExcelReports.csproj | 2 +- Reports/GoogleSheets/AssemblyInfo.cs | 35 +++ ...tuff.SQLViewPro.GoogleSheetsReports.csproj | 228 +++++++++++++++++ .../GoogleSheetsTemplateReportControl.ascx | 1 + .../GoogleSheetsTemplateReportControl.ascx.cs | 184 +++++++++++++ ...eetsTemplateReportControl.ascx.designer.cs | 30 +++ ...oogleSheetsTemplateReportControl.ascx.resx | 109 ++++++++ ...etsTemplateReportSettingsControl.ascx.resx | 162 ++++++++++++ ...leSheetsTemplateReportSettingsControl.ascx | 56 ++++ ...heetsTemplateReportSettingsControl.ascx.cs | 93 +++++++ ...lateReportSettingsControl.ascx.designer.cs | 159 ++++++++++++ Reports/GoogleSheets/packages.config | 5 + .../DNNStuff.SQLViewPro.SSRSReports.csproj | 2 +- ...DNNStuff.SQLViewPro.StandardReports.csproj | 2 +- Version/All/SQLViewPro.dnn | 8 + Version/Data/04.03.00.SqlDataProvider | 8 + docs/configuration.md | 25 ++ docs/googlesheetstemplate.md | 50 ++++ docs/index.md | 1 + packages.config | 6 + 29 files changed, 1669 insertions(+), 9 deletions(-) create mode 100644 Components/Services/DelineaClient.cs create mode 100644 Components/Services/GoogleSheets/GoogleSheetsClient.cs create mode 100644 Components/Services/GoogleSheets/GoogleSheetsClientException.cs create mode 100644 Reports/GoogleSheets/AssemblyInfo.cs create mode 100644 Reports/GoogleSheets/DNNStuff.SQLViewPro.GoogleSheetsReports.csproj create mode 100644 Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx create mode 100644 Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs create mode 100644 Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.designer.cs create mode 100644 Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.resx create mode 100644 Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx create mode 100644 Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx create mode 100644 Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs create mode 100644 Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs create mode 100644 Reports/GoogleSheets/packages.config create mode 100644 Version/Data/04.03.00.SqlDataProvider create mode 100644 docs/googlesheetstemplate.md diff --git a/Build/Build.bat b/Build/Build.bat index 8e6aa4a..d29245e 100644 --- a/Build/Build.bat +++ b/Build/Build.bat @@ -11,7 +11,7 @@ REM restore packages nuget.exe restore ..\ REM build install zip file -Msbuild.exe ModuleSpecific.targets /p:VisualStudioVersion=14.0;Version=%version%;Configuration=%buildconfig%;TargetFrameworkVersion=v4.5 /t:Install /l:FileLogger,Microsoft.Build.Engine;logfile=logs\Build_%buildconfig%.log;verbosity=diagnostic +Msbuild.exe ModuleSpecific.targets /p:VisualStudioVersion=14.0;Version=%version%;Configuration=%buildconfig%;TargetFrameworkVersion=v4.6 /t:Install /l:FileLogger,Microsoft.Build.Engine;logfile=logs\Build_%buildconfig%.log;verbosity=diagnostic if ERRORLEVEL 1 goto end -:end \ No newline at end of file +:end diff --git a/Components/Services/DelineaClient.cs b/Components/Services/DelineaClient.cs new file mode 100644 index 0000000..0ce31fc --- /dev/null +++ b/Components/Services/DelineaClient.cs @@ -0,0 +1,177 @@ +using System; +using System.Configuration; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; + +namespace DNNStuff.SQLViewPro.Services +{ + /// + /// Minimal client for Delinea Secret Server's REST API, used to retrieve secret field + /// values (e.g. the Google service-account JSON key) without storing them in this codebase. + /// Configuration is read from web.config appSettings: + /// DNNStuff:SQLViewPro:DelineaBaseUrl, DNNStuff:SQLViewPro:DelineaUsername, + /// DNNStuff:SQLViewPro:DelineaPassword. + /// + public class DelineaClient + { + private const int TokenExpiryBufferSeconds = 60; + + private static readonly object TokenLock = new object(); + private static string _cachedAccessToken; + private static DateTime _cachedTokenExpiresAtUtc = DateTime.MinValue; + + private readonly string _baseUrl; + private readonly string _username; + private readonly string _password; + + public DelineaClient() : this( + ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaBaseUrl"], + ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaUsername"], + ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaPassword"]) + { + } + + public DelineaClient(string baseUrl, string username, string password) + { + if (string.IsNullOrEmpty(baseUrl)) + { + throw new InvalidOperationException("DNNStuff:SQLViewPro:DelineaBaseUrl is not configured in web.config appSettings."); + } + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) + { + throw new InvalidOperationException("DNNStuff:SQLViewPro:DelineaUsername / DelineaPassword are not configured in web.config appSettings."); + } + + _baseUrl = baseUrl; + _username = username; + _password = password; + } + + /// + /// Looks up a secret by name and returns the value of the field identified by + /// (e.g. "data" for a JSON-key style secret). + /// + public string GetFieldValue(string secretName, string fieldSlug) + { + return GetFieldValueAsync(secretName, fieldSlug).GetAwaiter().GetResult(); + } + + public async Task GetFieldValueAsync(string secretName, string fieldSlug) + { + if (string.IsNullOrEmpty(secretName)) + { + throw new ArgumentException("secretName must be provided.", "secretName"); + } + if (string.IsNullOrEmpty(fieldSlug)) + { + throw new ArgumentException("fieldSlug must be provided.", "fieldSlug"); + } + + var accessToken = await GetAccessTokenAsync().ConfigureAwait(false); + + using (var client = CreateHttpClient(accessToken)) + { + var secretId = await FindSecretIdByNameAsync(client, secretName).ConfigureAwait(false); + return await GetSecretFieldValueAsync(client, secretId, fieldSlug).ConfigureAwait(false); + } + } + + private async Task GetAccessTokenAsync() + { + lock (TokenLock) + { + if (_cachedAccessToken != null && DateTime.UtcNow < _cachedTokenExpiresAtUtc) + { + return _cachedAccessToken; + } + } + + using (var client = new HttpClient()) + { + var form = new FormUrlEncodedContent(new[] + { + new System.Collections.Generic.KeyValuePair("grant_type", "password"), + new System.Collections.Generic.KeyValuePair("username", _username), + new System.Collections.Generic.KeyValuePair("password", _password) + }); + + var response = await client.PostAsync(_baseUrl + "/oauth2/token", form).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException(string.Format("Delinea token request failed ({0}): {1}", (int)response.StatusCode, body)); + } + + var json = JObject.Parse(body); + var accessToken = (string)json["access_token"]; + var expiresIn = (int?)json["expires_in"] ?? 0; + + lock (TokenLock) + { + _cachedAccessToken = accessToken; + _cachedTokenExpiresAtUtc = DateTime.UtcNow.AddSeconds(Math.Max(0, expiresIn - TokenExpiryBufferSeconds)); + } + + return accessToken; + } + } + + private static async Task FindSecretIdByNameAsync(HttpClient client, string secretName) + { + var url = string.Format("/api/v1/secrets?filter.searchText={0}", Uri.EscapeDataString(secretName)); + var response = await client.GetAsync(url).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException(string.Format("Delinea secret search failed ({0}): {1}", (int)response.StatusCode, body)); + } + + var json = JObject.Parse(body); + var records = json["records"] as JArray; + if (records == null || records.Count == 0) + { + throw new InvalidOperationException(string.Format("Delinea secret '{0}' was not found.", secretName)); + } + + return (int)records[0]["id"]; + } + + private static async Task GetSecretFieldValueAsync(HttpClient client, int secretId, string fieldSlug) + { + var response = await client.GetAsync(string.Format("/api/v1/secrets/{0}", secretId)).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + throw new InvalidOperationException(string.Format("Delinea secret fetch failed ({0}): {1}", (int)response.StatusCode, body)); + } + + var json = JObject.Parse(body); + var items = json["items"] as JArray; + if (items != null) + { + foreach (var item in items) + { + if (string.Equals((string)item["slug"], fieldSlug, StringComparison.OrdinalIgnoreCase)) + { + return (string)item["itemValue"]; + } + } + } + + throw new InvalidOperationException(string.Format("Delinea secret {0} does not have a field with slug '{1}'.", secretId, fieldSlug)); + } + + private HttpClient CreateHttpClient(string accessToken) + { + var client = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + return client; + } + } +} diff --git a/Components/Services/GoogleSheets/GoogleSheetsClient.cs b/Components/Services/GoogleSheets/GoogleSheetsClient.cs new file mode 100644 index 0000000..9866361 --- /dev/null +++ b/Components/Services/GoogleSheets/GoogleSheetsClient.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Configuration; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Google.Apis.Auth.OAuth2; +using Google.Apis.Drive.v3; +using Google.Apis.Services; +using Google.Apis.Sheets.v4; +using Google.Apis.Sheets.v4.Data; +using DNNStuff.SQLViewPro.Services; + +namespace DNNStuff.SQLViewPro.Services.GoogleSheets +{ + /// + /// Thin wrapper around the Google Drive v3 / Sheets v4 APIs used by the + /// "Google Sheets Template" report type: locate a template in Drive, clone it so the + /// original is never mutated, write report data into it (letting formulas/pivot tables + /// recalculate live), export the result as .xlsx, and clean up the temporary clone. + /// + /// The service-account JSON key is never stored in this codebase - it is retrieved on + /// demand from Delinea Secret Server via and the resulting + /// authenticated Drive/Sheets service clients are cached for . + /// + public class GoogleSheetsClient + { + private static readonly TimeSpan CredentialLifetime = TimeSpan.FromHours(1); + private static readonly string[] Scopes = { DriveService.Scope.Drive, SheetsService.Scope.Spreadsheets }; + + private static readonly object CredentialLock = new object(); + private static DriveService _cachedDriveService; + private static SheetsService _cachedSheetsService; + private static DateTime _cachedCredentialExpiresAtUtc = DateTime.MinValue; + + private readonly DelineaClient _delineaClient; + private readonly string _secretName; + private readonly string _fieldSlug; + + public GoogleSheetsClient() : this(new DelineaClient(), + ConfigurationManager.AppSettings["DNNStuff:SQLViewPro:DelineaGoogleSecretName"], + "json-key") + { + } + + public GoogleSheetsClient(DelineaClient delineaClient, string secretName, string fieldSlug) + { + if (delineaClient == null) + { + throw new ArgumentNullException("delineaClient"); + } + if (string.IsNullOrEmpty(secretName)) + { + throw new InvalidOperationException("DNNStuff:SQLViewPro:DelineaGoogleSecretName is not configured in web.config appSettings, and no secret name override was supplied."); + } + + _delineaClient = delineaClient; + _secretName = secretName; + _fieldSlug = fieldSlug; + } + + /// + /// Authenticates against Google (if needed) and returns the cached Drive/Sheets + /// service clients. Safe to call before every operation - it is a no-op once cached. + /// + public void Authenticate() + { + lock (CredentialLock) + { + if (_cachedDriveService != null && DateTime.UtcNow < _cachedCredentialExpiresAtUtc) + { + return; + } + } + + string serviceAccountJson; + try + { + serviceAccountJson = _delineaClient.GetFieldValue(_secretName, _fieldSlug); + } + catch (Exception ex) + { + throw new GoogleSheetsClientException(GoogleSheetsErrorType.Authentication, "Unable to retrieve the Google service-account key from Delinea Secret Server.", ex); + } + + try + { + GoogleCredential credential; + using (var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(serviceAccountJson))) + { + credential = GoogleCredential.FromStream(stream).CreateScoped(Scopes); + } + + var initializer = new BaseClientService.Initializer + { + HttpClientInitializer = credential, + ApplicationName = "DNNStuff SQLViewPro - Google Sheets Template" + }; + + lock (CredentialLock) + { + _cachedDriveService = new DriveService(initializer); + _cachedSheetsService = new SheetsService(initializer); + _cachedCredentialExpiresAtUtc = DateTime.UtcNow.Add(CredentialLifetime); + } + } + catch (Exception ex) + { + throw new GoogleSheetsClientException(GoogleSheetsErrorType.Authentication, "Unable to authenticate with Google using the retrieved service-account key.", ex); + } + } + + private DriveService Drive + { + get { return _cachedDriveService; } + } + + private SheetsService Sheets + { + get { return _cachedSheetsService; } + } + + /// + /// Finds a template file by name within a Drive folder (non-recursive) and returns its + /// file id. Throws when no match exists. + /// + public string FindTemplateByName(string folderId, string templateName) + { + try + { + var request = Drive.Files.List(); + request.Q = string.Format("'{0}' in parents and name = '{1}' and trashed = false", folderId, templateName.Replace("'", "\\'")); + request.Fields = "files(id, name)"; + request.PageSize = 1; + + var result = request.Execute(); + var file = result.Files != null ? result.Files.FirstOrDefault() : null; + if (file == null) + { + throw new GoogleSheetsClientException(GoogleSheetsErrorType.TemplateNotFound, string.Format("Template '{0}' was not found in Drive folder '{1}'.", templateName, folderId)); + } + + return file.Id; + } + catch (Google.GoogleApiException ex) + { + throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.TemplateNotFound, string.Format("Error searching for template '{0}'.", templateName), ex); + } + } + + /// + /// Clones the template spreadsheet so the original is never modified, and returns the + /// id of the new spreadsheet. + /// + public string CloneSpreadsheet(string templateFileId, string newFileName) + { + try + { + var copyMetadata = new Google.Apis.Drive.v3.Data.File { Name = newFileName }; + var request = Drive.Files.Copy(copyMetadata, templateFileId); + request.Fields = "id"; + var result = request.Execute(); + return result.Id; + } + catch (Google.GoogleApiException ex) + { + throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Clone, string.Format("Error cloning template '{0}'.", templateFileId), ex); + } + } + + /// Clears all values in the given A1 range (e.g. "Data!A2:Z"). + public void ClearRange(string spreadsheetId, string range) + { + try + { + var request = Sheets.Spreadsheets.Values.Clear(new ClearValuesRequest(), spreadsheetId, range); + request.Execute(); + } + catch (Google.GoogleApiException ex) + { + throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Write, string.Format("Error clearing range '{0}'.", range), ex); + } + } + + /// + /// Writes starting at the top-left cell of + /// (e.g. "Data!A1"), using USER_ENTERED so formulas typed into the range are evaluated. + /// + public void WriteData(string spreadsheetId, string range, IList> values) + { + try + { + var valueRange = new ValueRange { Values = values }; + var request = Sheets.Spreadsheets.Values.Update(valueRange, spreadsheetId, range); + request.ValueInputOption = SpreadsheetsResource.ValuesResource.UpdateRequest.ValueInputOptionEnum.USERENTERED; + request.Execute(); + } + catch (Google.GoogleApiException ex) + { + throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Write, string.Format("Error writing data to range '{0}'.", range), ex); + } + } + + /// + /// Exports the (already-recalculated) spreadsheet as an .xlsx byte array. + /// + public byte[] ExportAsXlsx(string spreadsheetId) + { + const string xlsxMimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + try + { + var request = Drive.Files.Export(spreadsheetId, xlsxMimeType); + using (var ms = new MemoryStream()) + { + request.Download(ms); + return ms.ToArray(); + } + } + catch (Google.GoogleApiException ex) + { + throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Export, string.Format("Error exporting spreadsheet '{0}' as xlsx.", spreadsheetId), ex); + } + } + + /// + /// Best-effort cleanup of the temporary cloned spreadsheet. Callers should invoke this + /// from a finally block and treat failures as non-fatal (log a warning, don't fail the report). + /// + public void DeleteSpreadsheet(string spreadsheetId) + { + try + { + Drive.Files.Delete(spreadsheetId).Execute(); + } + catch (Google.GoogleApiException ex) + { + throw GoogleSheetsClientException.FromGoogleApiException(GoogleSheetsErrorType.Delete, string.Format("Error deleting temporary spreadsheet '{0}'.", spreadsheetId), ex); + } + } + } +} diff --git a/Components/Services/GoogleSheets/GoogleSheetsClientException.cs b/Components/Services/GoogleSheets/GoogleSheetsClientException.cs new file mode 100644 index 0000000..1ce6be2 --- /dev/null +++ b/Components/Services/GoogleSheets/GoogleSheetsClientException.cs @@ -0,0 +1,54 @@ +using System; + +namespace DNNStuff.SQLViewPro.Services.GoogleSheets +{ + public enum GoogleSheetsErrorType + { + Authentication, + TemplateNotFound, + Clone, + Write, + Export, + Delete, + RateLimit, + Unknown + } + + /// + /// Wraps failures from operations with a categorized + /// so callers (report control, logging) can react + /// appropriately - e.g. retry on RateLimit, surface a friendly message on TemplateNotFound. + /// + public class GoogleSheetsClientException : Exception + { + public GoogleSheetsErrorType ErrorType { get; private set; } + + public GoogleSheetsClientException(GoogleSheetsErrorType errorType, string message) + : base(message) + { + ErrorType = errorType; + } + + public GoogleSheetsClientException(GoogleSheetsErrorType errorType, string message, Exception innerException) + : base(message, innerException) + { + ErrorType = errorType; + } + + /// + /// Maps a Google API exception to the appropriate error type, treating HTTP 429 + /// (and 403 quota-exceeded) responses as . + /// + public static GoogleSheetsClientException FromGoogleApiException(GoogleSheetsErrorType defaultErrorType, string message, Google.GoogleApiException ex) + { + var errorType = defaultErrorType; + if ((int)ex.HttpStatusCode == 429 || + (ex.HttpStatusCode == System.Net.HttpStatusCode.Forbidden && ex.Message != null && ex.Message.IndexOf("rate", StringComparison.OrdinalIgnoreCase) >= 0)) + { + errorType = GoogleSheetsErrorType.RateLimit; + } + + return new GoogleSheetsClientException(errorType, message, ex); + } + } +} diff --git a/DNNStuff.SQLViewPro.csproj b/DNNStuff.SQLViewPro.csproj index 7a85b5a..e0c3f9d 100644 --- a/DNNStuff.SQLViewPro.csproj +++ b/DNNStuff.SQLViewPro.csproj @@ -35,7 +35,7 @@ 4.0 - v4.0 + v4.6.1 false @@ -90,6 +90,21 @@ packages\DotNetNuke.Core.7.2.0.613\lib\net40\DotNetNuke.dll + + packages\Google.Apis.1.41.1\lib\net45\Google.Apis.dll + + + packages\Google.Apis.Auth.1.41.1\lib\net45\Google.Apis.Auth.dll + + + packages\Google.Apis.Core.1.41.1\lib\net45\Google.Apis.Core.dll + + + packages\Google.Apis.Drive.v3.1.41.1.1750\lib\net45\Google.Apis.Drive.v3.dll + + + packages\Google.Apis.Sheets.v4.1.41.1.1754\lib\net45\Google.Apis.Sheets.v4.dll + packages\Microsoft.ApplicationBlocks.Data.2.0.0\lib\net40\Microsoft.ApplicationBlocks.Data.dll @@ -98,10 +113,14 @@ Microsoft.VisualBasic + + packages\Newtonsoft.Json.13.0.4\lib\net45\Newtonsoft.Json.dll + System + System.Data @@ -132,6 +151,9 @@ + + + diff --git a/DNNStuff.SQLViewPro.sln b/DNNStuff.SQLViewPro.sln index 05dffad..83b9136 100644 --- a/DNNStuff.SQLViewPro.sln +++ b/DNNStuff.SQLViewPro.sln @@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DNNStuff.SQLViewPro.SSRSRep EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DNNStuff.SQLViewPro.MobileParameters", "Parameters\Mobile\DNNStuff.SQLViewPro.MobileParameters.csproj", "{2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DNNStuff.SQLViewPro.GoogleSheetsReports", "Reports\GoogleSheets\DNNStuff.SQLViewPro.GoogleSheetsReports.csproj", "{5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -44,6 +46,10 @@ Global {2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E8E89C2-A59F-4DE8-B49D-437556E8F6AF}.Release|Any CPU.Build.0 = Release|Any CPU + {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj b/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj index 5693c00..45de3cc 100644 --- a/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj +++ b/Parameters/Mobile/DNNStuff.SQLViewPro.MobileParameters.csproj @@ -35,7 +35,7 @@ 4.0 - v4.0 + v4.6.1 false diff --git a/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj b/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj index 7ef5671..ec93e46 100644 --- a/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj +++ b/Parameters/Standard/DNNStuff.SQLViewPro.StandardParameters.csproj @@ -35,7 +35,7 @@ 4.0 - v4.0 + v4.6.1 false diff --git a/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj b/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj index 9c61afd..e0052d2 100644 --- a/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj +++ b/Providers/SqlDataProvider/DNNStuff.SQLViewPro.SqlDataProvider.csproj @@ -33,7 +33,7 @@ 3.5 - v4.0 + v4.6.1 diff --git a/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj b/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj index 23d944c..90d42c6 100644 --- a/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj +++ b/Reports/Excel/DNNStuff.SQLViewPro.ExcelReports.csproj @@ -36,7 +36,7 @@ 4.0 true - v4.0 + v4.6.1 http://localhost/DNNStuff.SQLViewPro.ExcelReports/ true Web diff --git a/Reports/GoogleSheets/AssemblyInfo.cs b/Reports/GoogleSheets/AssemblyInfo.cs new file mode 100644 index 0000000..30a1195 --- /dev/null +++ b/Reports/GoogleSheets/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System; + + +using System.Reflection; +using System.Runtime.InteropServices; + + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. + +// Review the values of the assembly attributes + +[assembly:AssemblyTitle("")] +[assembly:AssemblyDescription("")] +[assembly:AssemblyCompany("")] +[assembly:AssemblyProduct("")] +[assembly:AssemblyCopyright("")] +[assembly:AssemblyTrademark("")] +[assembly:CLSCompliant(true)] + +//The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly:Guid("6A2B44C8-6E5A-4F3C-9B0F-3B9F8A5A9F16")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: + +[assembly:AssemblyVersion("1.0.0.0")] diff --git a/Reports/GoogleSheets/DNNStuff.SQLViewPro.GoogleSheetsReports.csproj b/Reports/GoogleSheets/DNNStuff.SQLViewPro.GoogleSheetsReports.csproj new file mode 100644 index 0000000..b2ee797 --- /dev/null +++ b/Reports/GoogleSheets/DNNStuff.SQLViewPro.GoogleSheetsReports.csproj @@ -0,0 +1,228 @@ + + + + Local + 9.0.30729 + 2.0 + {5A4B1C1A-B0B5-4621-ADA2-7BCB04605596} + {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} + Library + Debug + AnyCPU + + + + + DNNStuff.SQLViewPro.GoogleSheetsReports + + + None + JScript + Grid + IE50 + false + Library + Binary + On + On + + + + + + + Windows + + + 4.0 + true + v4.6.1 + http://localhost/DNNStuff.SQLViewPro.GoogleSheetsReports/ + true + Web + true + Foreground + 7 + Days + false + false + true + 0 + 1.0.0.%2a + false + true + false + + + + + + + + + + bin\ + DNNStuff.SQLViewPro.GoogleSheetsReports.xml + 285212672 + + + DEBUG + true + true + true + false + false + false + false + 1 + 1591,660,661 + full + + + bin\ + DNNStuff.SQLViewPro.GoogleSheetsReports.xml + 285212672 + + + + + false + true + false + true + false + false + false + 1 + 1591,660,661 + none + + + + ..\..\packages\DotNetNuke.Core.7.2.0.613\lib\net40\DotNetNuke.dll + + + ..\..\packages\Microsoft.ApplicationBlocks.Data.2.0.0\lib\net40\Microsoft.ApplicationBlocks.Data.dll + + + + + System + + + System.Data + + + + System.Web + + + + System.XML + + + + + + + + + Code + + + GoogleSheetsTemplateReportControl.ascx + + + GoogleSheetsTemplateReportControl.ascx + ASPXCodeBehind + + + GoogleSheetsTemplateReportControl.ascx.cs + Designer + + + GoogleSheetsTemplateReportSettingsControl.ascx + + + GoogleSheetsTemplateReportSettingsControl.ascx + ASPXCodeBehind + + + ASPXCodeBehind + + + + + + + False + .NET Framework 3.5 SP1 Client Profile + false + + + False + .NET Framework 2.0 %28x86%29 + true + + + False + .NET Framework 3.0 %28x86%29 + false + + + False + .NET Framework 3.5 + false + + + False + .NET Framework 3.5 SP1 + false + + + + + {0440098f-ff3b-41a4-a074-45b878f929b9} + DNNStuff.SQLViewPro + + + + + + + + + + + 10.0 + $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) + + + + + + + + + + + + + True + True + 62053 + / + http://dnn7.dnndev.me/DesktopModules/DNNStuff - SQLViewPro/Reports/GoogleSheets + True + http://dnn7.dnndev.me + False + False + + + False + + + + + + diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx new file mode 100644 index 0000000..82036b2 --- /dev/null +++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx @@ -0,0 +1 @@ +<%@ Control Language="C#" Inherits="DNNStuff.SQLViewPro.GoogleSheetsReports.GoogleSheetsTemplateReportControl" CodeBehind="GoogleSheetsTemplateReportControl.ascx.cs" AutoEventWireup="true" Explicit="True" targetSchema="http://schemas.microsoft.com/intellisense/ie5" %> diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs new file mode 100644 index 0000000..0956443 --- /dev/null +++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Web.UI; +using DotNetNuke.Common; +using DNNStuff.SQLViewPro.Services; +using DNNStuff.SQLViewPro.Services.GoogleSheets; + +namespace DNNStuff.SQLViewPro.GoogleSheetsReports +{ + public partial class GoogleSheetsTemplateReportControl : Controls.ReportControlBase + { + +#region Web Form Designer Generated Code + + //This call is required by the Web Form Designer. + [System.Diagnostics.DebuggerStepThrough()]private void InitializeComponent() + { + + } + + private void Page_Init(Object sender, EventArgs e) + { + //CODEGEN: This method call is required by the Web Form Designer + //Do not modify it using the code editor. + InitializeComponent(); + + try + { + if (Globals.IsEditMode()) + { + Controls.Add(new LiteralControl("Please switch to view mode to generate the Google Sheets Template file")); + } + else + { + ProcessGoogleSheetsTemplate(); + } + } + catch (GoogleSheetsClientException ex) + { + DotNetNuke.Services.Exceptions.Exceptions.LogException(ex); + Controls.Add(new LiteralControl(string.Format("Unable to generate the Google Sheets Template file ({0}): {1}", ex.ErrorType, ex.Message))); + } + catch (Exception ex) + { + DotNetNuke.Services.Exceptions.Exceptions.ProcessModuleLoadException(this, ex); + } + } + +#endregion + +#region Page + + private GoogleSheetsTemplateReportSettings ReportExtra { get; set; } = new GoogleSheetsTemplateReportSettings(); + +#endregion + +#region Base Method Implementations + public override void LoadRuntimeSettings(ReportInfo Settings) + { + ReportExtra = (GoogleSheetsTemplateReportSettings) (Serialization.DeserializeObject(Settings.ReportConfig, typeof(GoogleSheetsTemplateReportSettings))); + } +#endregion + +#region Google Sheets Template + private void ProcessGoogleSheetsTemplate() + { + var ds = ReportData(); + + // add debug info + if (State.ReportSet.ReportSetDebug) + { + DebugInfo.Append(QueryText); + } + + if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0) + { + RenderNoItems(); + } + else + { + RenderGoogleSheetsTemplate(ds.Tables[0]); + } + } + + private void RenderGoogleSheetsTemplate(DataTable dt) + { + var client = CreateClient(); + client.Authenticate(); + + var templateFileId = client.FindTemplateByName(ReportExtra.DriveFolderId, ReportExtra.TemplateName); + + var outputFileName = ReportExtra.OutputFileName.Replace("[TICKS]", DateTime.Now.Ticks.ToString()); + var spreadsheetId = client.CloneSpreadsheet(templateFileId, outputFileName); + + try + { + var dataSheetName = ReportExtra.DataSheetName; + + if (ReportExtra.ContainsHeaderRow) + { + // keep the existing header row - clear/write starting on row 2 + client.ClearRange(spreadsheetId, string.Format("{0}!A2", dataSheetName)); + client.WriteData(spreadsheetId, string.Format("{0}!A2", dataSheetName), BuildValueRows(dt, includeHeader: false)); + } + else + { + // no existing header - clear the whole sheet and write our own header row + client.ClearRange(spreadsheetId, dataSheetName); + client.WriteData(spreadsheetId, string.Format("{0}!A1", dataSheetName), BuildValueRows(dt, includeHeader: true)); + } + + var xlsxBytes = client.ExportAsXlsx(spreadsheetId); + + var details = new ExportDetails(); + details.Binary = xlsxBytes; + details.Filename = outputFileName + ".xlsx"; + details.Disposition = ReportExtra.DispositionType; + + Session[Export.EXPORT_KEY] = details; + + if (Request.ServerVariables["HTTP_USER_AGENT"].Contains("ipad") || Request.ServerVariables["HTTP_USER_AGENT"].Contains("iphone")) + { + //' no iframe for iphone, ipad + Response.Redirect(string.Format("{0}?ModuleId={1}&TabId={2}", ResolveUrl("~/DesktopModules/DNNStuff - SQLViewPro/Export.aspx"), State.ModuleId, State.TabId)); + } + else + { + Controls.Add(new LiteralControl(string.Format("", ResolveUrl("~/DesktopModules/DNNStuff - SQLViewPro/Export.aspx"), State.ModuleId, State.TabId))); + } + } + finally + { + // best-effort cleanup of the temporary clone - never fail the report because of it + try + { + client.DeleteSpreadsheet(spreadsheetId); + } + catch (Exception ex) + { + DotNetNuke.Services.Exceptions.Exceptions.LogException(ex); + } + } + } + + private GoogleSheetsClient CreateClient() + { + if (!string.IsNullOrEmpty(ReportExtra.SecretNameOverride)) + { + return new GoogleSheetsClient(new DelineaClient(), ReportExtra.SecretNameOverride, "json-key"); + } + + return new GoogleSheetsClient(); + } + + private static IList> BuildValueRows(DataTable dt, bool includeHeader) + { + var rows = new List>(); + + if (includeHeader) + { + var headerRow = new List(); + foreach (DataColumn column in dt.Columns) + { + headerRow.Add(column.ColumnName); + } + rows.Add(headerRow); + } + + foreach (DataRow dataRow in dt.Rows) + { + var row = new List(); + for (var col = 0; col <= dt.Columns.Count - 1; col++) + { + row.Add(dataRow[col]); + } + rows.Add(row); + } + + return rows; + } +#endregion + } +} diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.designer.cs b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.designer.cs new file mode 100644 index 0000000..129777d --- /dev/null +++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.designer.cs @@ -0,0 +1,30 @@ + +using System.Web.UI.HtmlControls; +using System; +using System.Diagnostics; +using System.Data; +using System.Web.UI.WebControls; +using Microsoft.VisualBasic; +using System.Collections; +using System.Web.UI; +using System.Web; + + + +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +namespace DNNStuff.SQLViewPro.GoogleSheetsReports +{ + + public partial class GoogleSheetsTemplateReportControl + { + } +} diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.resx b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.resx new file mode 100644 index 0000000..b9eacda --- /dev/null +++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.resx @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=1.0.5000.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=1.0.5000.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + True + + + False + + + Assembly + + \ No newline at end of file diff --git a/Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx b/Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx new file mode 100644 index 0000000..eab2594 --- /dev/null +++ b/Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The Google Drive folder id that contains the template spreadsheet (found in the folder's URL) + + + Drive Folder ID + + + The template spreadsheet's file name within the Drive folder + + + Template Name + + + The name of the sheet where the raw data will appear + + + Data Sheet Name + + + Check if your sheet already contains a header row + + + Contains Header Row + + + The prefix used to name the output file. [TICKS] is replaced with the current tick count. .xlsx is appended automatically. + + + Output Filename Prefix + + + The disposition type used when streaming the file to the browser + + + Disposition Type + + + Optional: overrides the default Delinea secret name used to retrieve the Google service-account key for this report + + + Secret Name Override + + diff --git a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx new file mode 100644 index 0000000..0af3e47 --- /dev/null +++ b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx @@ -0,0 +1,56 @@ +<%@ Control Language="C#" AutoEventWireup="true" CodeBehind="GoogleSheetsTemplateReportSettingsControl.ascx.cs" Inherits="DNNStuff.SQLViewPro.GoogleSheetsReports.GoogleSheetsTemplateReportSettingsControl" EnableViewState="true" %> +<%@ Register TagPrefix="dnn" TagName="Label" Src="~/controls/LabelControl.ascx" %> + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + inline + attachment + +
+
+ + +
+
+
+
+ diff --git a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs new file mode 100644 index 0000000..ddd0235 --- /dev/null +++ b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs @@ -0,0 +1,93 @@ +using System.Xml.Serialization; +using DNNStuff.SQLViewPro.Controls; + +namespace DNNStuff.SQLViewPro.GoogleSheetsReports +{ + + public partial class GoogleSheetsTemplateReportSettingsControl : ReportSettingsControlBase + { + +#region Web Form Designer Generated Code + + //This call is required by the Web Form Designer. + [System.Diagnostics.DebuggerStepThrough()]private void InitializeComponent() + { + + } + + private void Page_Init(System.Object sender, System.EventArgs e) + { + //CODEGEN: This method call is required by the Web Form Designer + //Do not modify it using the code editor. + InitializeComponent(); + } + +#endregion + + +#region Base Method Implementations + protected override string LocalResourceFile => ResolveUrl("App_LocalResources/GoogleSheetsTemplateReportSettingsControl"); + + public override string UpdateSettings() + { + + var obj = new GoogleSheetsTemplateReportSettings(); + obj.DriveFolderId = txtDriveFolderId.Text; + obj.TemplateName = txtTemplateName.Text; + obj.DataSheetName = txtDataSheetName.Text; + obj.ContainsHeaderRow = chkContainsHeaderRow.Checked; + obj.OutputFileName = txtOutputFileName.Text; + obj.DispositionType = ddDispositionType.SelectedValue; + obj.SecretNameOverride = txtSecretNameOverride.Text; + + return Serialization.SerializeObject(obj, typeof(GoogleSheetsTemplateReportSettings)); + + } + + public override void LoadSettings(string settings) + { + var obj = new GoogleSheetsTemplateReportSettings(); + if (!string.IsNullOrEmpty(settings)) + { + obj = (GoogleSheetsTemplateReportSettings) (Serialization.DeserializeObject(settings, typeof(GoogleSheetsTemplateReportSettings))); + } + txtDriveFolderId.Text = obj.DriveFolderId; + txtTemplateName.Text = obj.TemplateName; + txtDataSheetName.Text = obj.DataSheetName; + chkContainsHeaderRow.Checked = obj.ContainsHeaderRow; + txtOutputFileName.Text = obj.OutputFileName; + txtSecretNameOverride.Text = obj.SecretNameOverride; + + ControlHelpers.InitDropDownByValue(ddDispositionType, obj.DispositionType); + } + +#endregion + + } + +#region Settings + /// + /// Per-report configuration for the "Google Sheets Template" report type. Follows the + /// same XML-serialized settings pattern as ExcelTemplateReportSettings. + /// + [XmlRootAttribute(ElementName = "Settings", IsNullable = false)]public class GoogleSheetsTemplateReportSettings + { + /// The Google Drive folder id that contains the template spreadsheet. + public string DriveFolderId {get; set;} + /// The template spreadsheet's file name within . + public string TemplateName {get; set;} + /// The sheet name within the template where report data is written. + public string DataSheetName {get; set;} + /// Prefix for the exported file name. "[TICKS]" is replaced with the current tick count. + public string OutputFileName {get; set;} + public bool ContainsHeaderRow {get; set;} + public string DispositionType { get; set; } = "attachment"; + /// + /// Optional override for the Delinea secret name that holds the Google service-account + /// JSON key. When blank, DNNStuff:SQLViewPro:DelineaGoogleSecretName is used. + /// + public string SecretNameOverride { get; set; } = ""; + } +#endregion + +} diff --git a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs new file mode 100644 index 0000000..535b5b3 --- /dev/null +++ b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs @@ -0,0 +1,159 @@ + +using System.Web.UI.HtmlControls; +using System; +using System.Diagnostics; +using System.Data; +using System.Web.UI.WebControls; +using Microsoft.VisualBasic; +using System.Collections; +using System.Web.UI; +using System.Web; +using DotNetNuke.UI.UserControls; + + + + +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + + +namespace DNNStuff.SQLViewPro.GoogleSheetsReports +{ + + public partial class GoogleSheetsTemplateReportSettingsControl + { + + /// + ///lblDriveFolderId control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected LabelControl lblDriveFolderId; + + /// + ///txtDriveFolderId control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected System.Web.UI.WebControls.TextBox txtDriveFolderId; + + /// + ///lblTemplateName control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected LabelControl lblTemplateName; + + /// + ///txtTemplateName control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected System.Web.UI.WebControls.TextBox txtTemplateName; + + /// + ///lblDataSheetName control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected LabelControl lblDataSheetName; + + /// + ///txtDataSheetName control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected System.Web.UI.WebControls.TextBox txtDataSheetName; + + /// + ///lblContainsHeaderRow control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected LabelControl lblContainsHeaderRow; + + /// + ///chkContainsHeaderRow control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected System.Web.UI.WebControls.CheckBox chkContainsHeaderRow; + + /// + ///lblOutputFileName control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected LabelControl lblOutputFileName; + + /// + ///txtOutputFileName control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected System.Web.UI.WebControls.TextBox txtOutputFileName; + + /// + ///lblDispositionType control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected LabelControl lblDispositionType; + + /// + ///ddDispositionType control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected System.Web.UI.WebControls.DropDownList ddDispositionType; + + /// + ///lblSecretNameOverride control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected LabelControl lblSecretNameOverride; + + /// + ///txtSecretNameOverride control. + /// + /// + ///Auto-generated field. + ///To modify move field declaration from designer file to code-behind file. + /// + protected System.Web.UI.WebControls.TextBox txtSecretNameOverride; + } +} + diff --git a/Reports/GoogleSheets/packages.config b/Reports/GoogleSheets/packages.config new file mode 100644 index 0000000..1f4454b --- /dev/null +++ b/Reports/GoogleSheets/packages.config @@ -0,0 +1,5 @@ + + + + + diff --git a/Reports/SSRS/DNNStuff.SQLViewPro.SSRSReports.csproj b/Reports/SSRS/DNNStuff.SQLViewPro.SSRSReports.csproj index 104e624..d3b7f22 100644 --- a/Reports/SSRS/DNNStuff.SQLViewPro.SSRSReports.csproj +++ b/Reports/SSRS/DNNStuff.SQLViewPro.SSRSReports.csproj @@ -36,7 +36,7 @@ 4.0 true - v4.0 + v4.6.1 http://localhost/DNNStuff.SQLViewPro.SSRSReports/ true Web diff --git a/Reports/Standard/DNNStuff.SQLViewPro.StandardReports.csproj b/Reports/Standard/DNNStuff.SQLViewPro.StandardReports.csproj index c5e61d0..cf833f0 100644 --- a/Reports/Standard/DNNStuff.SQLViewPro.StandardReports.csproj +++ b/Reports/Standard/DNNStuff.SQLViewPro.StandardReports.csproj @@ -36,7 +36,7 @@ 4.0 true - v4.0 + v4.6.1 http://localhost/DNNStuff.SQLViewPro.StandardReports/ true Web diff --git a/Version/All/SQLViewPro.dnn b/Version/All/SQLViewPro.dnn index 26daf27..ae78efc 100644 --- a/Version/All/SQLViewPro.dnn +++ b/Version/All/SQLViewPro.dnn @@ -104,6 +104,10 @@ DNNStuff.SQLViewPro.SqlDataProvider.dll DNNStuff.SQLViewPro.SqlDataProvider.dll + + DNNStuff.SQLViewPro.GoogleSheetsReports.dll + DNNStuff.SQLViewPro.GoogleSheetsReports.dll + DNNStuff.Utilities.dll DNNStuff.Utilities.dll @@ -233,6 +237,10 @@ 04.00.18.SqlDataProvider 04.00.18 + diff --git a/Version/Data/04.03.00.SqlDataProvider b/Version/Data/04.03.00.SqlDataProvider new file mode 100644 index 0000000..8668305 --- /dev/null +++ b/Version/Data/04.03.00.SqlDataProvider @@ -0,0 +1,8 @@ +INSERT INTO {databaseOwner}[{objectQualifier}DNNStuff_SqlViewPro_ReportType] ( + [ReportTypeId], + [ReportTypeName], + [ReportTypeControlSrc], + [ReportTypeSettingsControlSrc] +) + SELECT 'GOOGLESHEETSTEMPLATE', 'Google Sheets Template', 'Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx', 'Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx' +GO diff --git a/docs/configuration.md b/docs/configuration.md index 9336ae5..b2635f3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -64,3 +64,28 @@ particular report set. For instance, you can create a parameter that displays a drop down list of countries. See [Parameters](parameters) + +Web.config Application Settings +-------------------------------- + +Some features read configuration from the host site's `web.config` `` +section rather than from the SQLView Pro UI, since they involve credentials or +environment-specific values that shouldn't be stored in the module's own settings. + +The [Google Sheets Template](googlesheetstemplate) report type requires the +following keys, used to retrieve the Google service-account key from Delinea +Secret Server: + +```xml + + + + + + +``` + +`DelineaGoogleSecretName` is the default secret used by all Google Sheets Template +reports; an individual report can override it via its **Secret Name Override** +setting. + diff --git a/docs/googlesheetstemplate.md b/docs/googlesheetstemplate.md new file mode 100644 index 0000000..0a42249 --- /dev/null +++ b/docs/googlesheetstemplate.md @@ -0,0 +1,50 @@ +# SQLView Pro Google Sheets Template Report + +The Google Sheets Template report type works like the [Excel Template](exceltemplate) +report, but the template lives in Google Drive instead of the DNN file system. At +render time SQLView Pro clones the template, writes the report's query results into +it live (so any formulas, charts or pivot tables in the template recalculate using +Google's own engine), exports the result as an `.xlsx` file, streams it to the +browser, and then deletes the temporary clone. + +### Google Sheets Template Report Fields + +- Drive Folder ID – the Google Drive folder id that contains the template + spreadsheet (this is the long id segment in the folder's URL) +- Template Name – the template spreadsheet's file name within that Drive folder +- Data Sheet Name – the sheet (tab) within the template where report data is + written +- Contains Header Row – check this if the sheet already has a header row you + want to keep; data is then written starting on row 2. When unchecked, the + whole sheet is cleared and a header row is generated from the report's + column names. +- Output Filename Prefix – the prefix used to name the exported `.xlsx` file. + `[TICKS]` is replaced with the current tick count to keep file names unique. +- Disposition Type – `inline` or `attachment`, same as the Excel Template report +- Secret Name Override – optional; overrides the default Delinea Secret Server + secret name used to retrieve the Google service-account key for this specific + report. Leave blank to use the site-wide default. + +### Prerequisites + +- A Google Cloud service account with the Drive and Sheets APIs enabled. +- The target Drive folder (and the template spreadsheet within it) must be + **shared with the service account's email address** - without this the + report will fail to find or clone the template. +- The service account's JSON key must be stored in Delinea Secret Server as a + secret with a field slug of `json-key`, and the following `appSettings` must + be configured in the host site's `web.config` (see [Configuration](configuration)): + - `DNNStuff:SQLViewPro:DelineaBaseUrl` + - `DNNStuff:SQLViewPro:DelineaUsername` + - `DNNStuff:SQLViewPro:DelineaPassword` + - `DNNStuff:SQLViewPro:DelineaGoogleSecretName` - the default secret name + used when a report doesn't specify a Secret Name Override + +### Notes + +- The original template is never modified - each report run clones it, writes + to the clone, and deletes the clone once the export completes (even if the + export itself fails). +- Because writing uses Google's `USER_ENTERED` input option, formulas typed + into the data range are evaluated the same way they would be if typed by a + user in Sheets. diff --git a/docs/index.md b/docs/index.md index 3e7001c..1d0ab8d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,6 +29,7 @@ Report Types * [Grid](grid) * [Template](template) * [Excel Template](exceltemplate) +* [Google Sheets Template](googlesheetstemplate) * [SQL Server Reporting Services (SSRS)](ssrs) Parameter Types diff --git a/packages.config b/packages.config index 299d8e8..f034d32 100644 --- a/packages.config +++ b/packages.config @@ -1,6 +1,12 @@  + + + + + + \ No newline at end of file From 8b222294bb96fe596fe2930a13d76e091b3a1fd1 Mon Sep 17 00:00:00 2001 From: Andrew Leverette Date: Wed, 15 Jul 2026 11:57:41 -0500 Subject: [PATCH 2/4] fix(googleSheets): ensure that new files get created in parent directory --- .../GoogleSheets/GoogleSheetsClient.cs | 21 ++++++++++++++++--- .../GoogleSheetsTemplateReportControl.ascx.cs | 2 +- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/Components/Services/GoogleSheets/GoogleSheetsClient.cs b/Components/Services/GoogleSheets/GoogleSheetsClient.cs index 9866361..881f64a 100644 --- a/Components/Services/GoogleSheets/GoogleSheetsClient.cs +++ b/Components/Services/GoogleSheets/GoogleSheetsClient.cs @@ -132,6 +132,11 @@ public string FindTemplateByName(string folderId, string templateName) request.Q = string.Format("'{0}' in parents and name = '{1}' and trashed = false", folderId, templateName.Replace("'", "\\'")); request.Fields = "files(id, name)"; request.PageSize = 1; + // The template folder lives on a shared drive - all + // three of these are required together for Drive to search shared-drive content. + request.SupportsAllDrives = true; + request.IncludeItemsFromAllDrives = true; + request.Corpora = "allDrives"; var result = request.Execute(); var file = result.Files != null ? result.Files.FirstOrDefault() : null; @@ -150,15 +155,23 @@ public string FindTemplateByName(string folderId, string templateName) /// /// Clones the template spreadsheet so the original is never modified, and returns the - /// id of the new spreadsheet. + /// id of the new spreadsheet. The clone is explicitly placed in + /// so the caller always knows - and controls - where the temporary clone lives. /// - public string CloneSpreadsheet(string templateFileId, string newFileName) + public string CloneSpreadsheet(string templateFileId, string newFileName, string parentFolderId) { try { var copyMetadata = new Google.Apis.Drive.v3.Data.File { Name = newFileName }; + if (!string.IsNullOrEmpty(parentFolderId)) + { + copyMetadata.Parents = new List { parentFolderId }; + } + var request = Drive.Files.Copy(copyMetadata, templateFileId); request.Fields = "id"; + // Required so the copy succeeds when the template lives on a shared drive. + request.SupportsAllDrives = true; var result = request.Execute(); return result.Id; } @@ -230,7 +243,9 @@ public void DeleteSpreadsheet(string spreadsheetId) { try { - Drive.Files.Delete(spreadsheetId).Execute(); + var request = Drive.Files.Delete(spreadsheetId); + request.SupportsAllDrives = true; + request.Execute(); } catch (Google.GoogleApiException ex) { diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs index 0956443..9238bd0 100644 --- a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs +++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs @@ -91,7 +91,7 @@ private void RenderGoogleSheetsTemplate(DataTable dt) var templateFileId = client.FindTemplateByName(ReportExtra.DriveFolderId, ReportExtra.TemplateName); var outputFileName = ReportExtra.OutputFileName.Replace("[TICKS]", DateTime.Now.Ticks.ToString()); - var spreadsheetId = client.CloneSpreadsheet(templateFileId, outputFileName); + var spreadsheetId = client.CloneSpreadsheet(templateFileId, outputFileName, ReportExtra.DriveFolderId); try { From 47c91670e7196a371de36c959fab6900037afbe6 Mon Sep 17 00:00:00 2001 From: Andrew Leverette Date: Wed, 15 Jul 2026 12:58:29 -0500 Subject: [PATCH 3/4] fix: add new dll targets --- Build/ModuleSpecific.targets | 11 +++++++++++ Version/All/SQLViewPro.dnn | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Build/ModuleSpecific.targets b/Build/ModuleSpecific.targets index 0a79e1f..8fca268 100644 --- a/Build/ModuleSpecific.targets +++ b/Build/ModuleSpecific.targets @@ -11,6 +11,7 @@ + @@ -40,6 +41,10 @@ + + + + @@ -50,6 +55,12 @@ + + + + + + \ No newline at end of file diff --git a/Version/All/SQLViewPro.dnn b/Version/All/SQLViewPro.dnn index ae78efc..11ebd32 100644 --- a/Version/All/SQLViewPro.dnn +++ b/Version/All/SQLViewPro.dnn @@ -120,6 +120,30 @@ Microsoft.ReportViewer.WebForms.dll Microsoft.ReportViewer.WebForms.dll + + Google.Apis.dll + Google.Apis.dll + + + Google.Apis.Auth.dll + Google.Apis.Auth.dll + + + Google.Apis.Core.dll + Google.Apis.Core.dll + + + Google.Apis.Drive.v3.dll + Google.Apis.Drive.v3.dll + + + Google.Apis.Sheets.v4.dll + Google.Apis.Sheets.v4.dll + + + Newtonsoft.Json.dll + Newtonsoft.Json.dll + From e33b8c1c9d2725357308ac32f61c38059bd7a70a Mon Sep 17 00:00:00 2001 From: Andrew Leverette Date: Wed, 15 Jul 2026 14:11:36 -0500 Subject: [PATCH 4/4] fix(googlSheets): remove secret name override from page as not needed --- .../GoogleSheetsTemplateReportControl.ascx.cs | 6 ------ ...eetsTemplateReportSettingsControl.ascx.resx | 6 ------ ...gleSheetsTemplateReportSettingsControl.ascx | 4 ---- ...SheetsTemplateReportSettingsControl.ascx.cs | 7 ------- ...plateReportSettingsControl.ascx.designer.cs | 18 ------------------ docs/configuration.md | 5 ++--- docs/googlesheetstemplate.md | 7 ++----- 7 files changed, 4 insertions(+), 49 deletions(-) diff --git a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs index 9238bd0..21470ea 100644 --- a/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs +++ b/Reports/GoogleSheets/Report/GoogleSheetsTemplate/GoogleSheetsTemplateReportControl.ascx.cs @@ -3,7 +3,6 @@ using System.Data; using System.Web.UI; using DotNetNuke.Common; -using DNNStuff.SQLViewPro.Services; using DNNStuff.SQLViewPro.Services.GoogleSheets; namespace DNNStuff.SQLViewPro.GoogleSheetsReports @@ -145,11 +144,6 @@ private void RenderGoogleSheetsTemplate(DataTable dt) private GoogleSheetsClient CreateClient() { - if (!string.IsNullOrEmpty(ReportExtra.SecretNameOverride)) - { - return new GoogleSheetsClient(new DelineaClient(), ReportExtra.SecretNameOverride, "json-key"); - } - return new GoogleSheetsClient(); } diff --git a/Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx b/Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx index eab2594..460b27b 100644 --- a/Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx +++ b/Reports/GoogleSheets/Settings/App_LocalResources/GoogleSheetsTemplateReportSettingsControl.ascx.resx @@ -153,10 +153,4 @@ Disposition Type - - Optional: overrides the default Delinea secret name used to retrieve the Google service-account key for this report - - - Secret Name Override - diff --git a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx index 0af3e47..e15b76d 100644 --- a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx +++ b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx @@ -31,10 +31,6 @@ attachment -
- - -
diff --git a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs index ddd0235..887ac5d 100644 --- a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs +++ b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.cs @@ -38,7 +38,6 @@ public override string UpdateSettings() obj.ContainsHeaderRow = chkContainsHeaderRow.Checked; obj.OutputFileName = txtOutputFileName.Text; obj.DispositionType = ddDispositionType.SelectedValue; - obj.SecretNameOverride = txtSecretNameOverride.Text; return Serialization.SerializeObject(obj, typeof(GoogleSheetsTemplateReportSettings)); @@ -56,7 +55,6 @@ public override void LoadSettings(string settings) txtDataSheetName.Text = obj.DataSheetName; chkContainsHeaderRow.Checked = obj.ContainsHeaderRow; txtOutputFileName.Text = obj.OutputFileName; - txtSecretNameOverride.Text = obj.SecretNameOverride; ControlHelpers.InitDropDownByValue(ddDispositionType, obj.DispositionType); } @@ -82,11 +80,6 @@ public override void LoadSettings(string settings) public string OutputFileName {get; set;} public bool ContainsHeaderRow {get; set;} public string DispositionType { get; set; } = "attachment"; - /// - /// Optional override for the Delinea secret name that holds the Google service-account - /// JSON key. When blank, DNNStuff:SQLViewPro:DelineaGoogleSecretName is used. - /// - public string SecretNameOverride { get; set; } = ""; } #endregion diff --git a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs index 535b5b3..5198fbb 100644 --- a/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs +++ b/Reports/GoogleSheets/Settings/GoogleSheetsTemplateReportSettingsControl.ascx.designer.cs @@ -136,24 +136,6 @@ public partial class GoogleSheetsTemplateReportSettingsControl ///To modify move field declaration from designer file to code-behind file. /// protected System.Web.UI.WebControls.DropDownList ddDispositionType; - - /// - ///lblSecretNameOverride control. - /// - /// - ///Auto-generated field. - ///To modify move field declaration from designer file to code-behind file. - /// - protected LabelControl lblSecretNameOverride; - - /// - ///txtSecretNameOverride control. - /// - /// - ///Auto-generated field. - ///To modify move field declaration from designer file to code-behind file. - /// - protected System.Web.UI.WebControls.TextBox txtSecretNameOverride; } } diff --git a/docs/configuration.md b/docs/configuration.md index b2635f3..1a7f49c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,7 +85,6 @@ Secret Server: ``` -`DelineaGoogleSecretName` is the default secret used by all Google Sheets Template -reports; an individual report can override it via its **Secret Name Override** -setting. +`DelineaGoogleSecretName` is the secret used by all Google Sheets Template +reports. diff --git a/docs/googlesheetstemplate.md b/docs/googlesheetstemplate.md index 0a42249..11484d9 100644 --- a/docs/googlesheetstemplate.md +++ b/docs/googlesheetstemplate.md @@ -21,9 +21,6 @@ browser, and then deletes the temporary clone. - Output Filename Prefix – the prefix used to name the exported `.xlsx` file. `[TICKS]` is replaced with the current tick count to keep file names unique. - Disposition Type – `inline` or `attachment`, same as the Excel Template report -- Secret Name Override – optional; overrides the default Delinea Secret Server - secret name used to retrieve the Google service-account key for this specific - report. Leave blank to use the site-wide default. ### Prerequisites @@ -37,8 +34,8 @@ browser, and then deletes the temporary clone. - `DNNStuff:SQLViewPro:DelineaBaseUrl` - `DNNStuff:SQLViewPro:DelineaUsername` - `DNNStuff:SQLViewPro:DelineaPassword` - - `DNNStuff:SQLViewPro:DelineaGoogleSecretName` - the default secret name - used when a report doesn't specify a Secret Name Override + - `DNNStuff:SQLViewPro:DelineaGoogleSecretName` - the secret name used to + retrieve the Google service-account key ### Notes